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/tree/open
5//! over the control socket, plus a tray submenu with a per-window "focus" action.
6//! The `open` op (#1266) focuses/opens an arbitrary worktree folder in VS Code
7//! through the **same** launcher path the tray uses, so a socket client (the
8//! companion's double-click) shares the tested guard and launcher resolution
9//! rather than duplicating them.
10//!
11//! All registry state and liveness logic (the `Mutex<HashMap>`, TTL reaping, the
12//! entry cap/eviction) lives in [`crate::worktrees`]; this adapter only routes
13//! ops, renders the menu/status, and drives the VS Code launcher. Like the
14//! Snowflake service it is a cheap, in-memory adapter — no async setup, no
15//! secret persisted.
16//!
17//! The adapter also computes the **per-worktree git enrichment** (current
18//! branch, ahead/behind counts, and the parent repository a linked worktree
19//! belongs to) on read via `git2` (#1186), keeping the companion a thin reporter
20//! of raw folder paths (ADR-0040). The engine stores only what the companion
21//! sends; disk I/O for the enrichment lives here, alongside the launcher, never
22//! under the registry lock.
23//!
24//! The `tree` op (#1265) inverts the data model for the companion's tree view:
25//! from the open windows the adapter derives the **distinct repositories**, then
26//! enumerates **all** of each repo's worktrees (main working tree +
27//! [`Repository::worktrees`]), enriches each (reusing [`git_status`]), tags the
28//! GitHub identity of `origin`, and joins the open windows back on by
29//! canonicalized path. The open-window registry stays the liveness source;
30//! "is a window open on it?" becomes a per-worktree attribute. All of this is
31//! git disk I/O, so it runs on a blocking thread, never under the registry lock.
32
33use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, PoisonError};
38use std::time::{Duration, Instant};
39
40use anyhow::{anyhow, bail, Context, Result};
41use async_trait::async_trait;
42use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
43use serde::{Deserialize, Serialize};
44use serde_json::{json, Value};
45use tokio::sync::watch;
46use tokio::sync::Mutex as AsyncMutex;
47use tokio::task::JoinHandle;
48use tokio_util::sync::CancellationToken;
49
50use crate::daemon::service::{
51    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
52};
53use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
54
55/// The worktrees service name (the control-socket routing key).
56pub const SERVICE_NAME: &str = "worktrees";
57
58/// The tray submenu title.
59const SUBMENU_TITLE: &str = "Worktrees";
60
61/// Environment override for the VS Code launcher used by the "focus" tray
62/// action, for when the daemon runs under launchd with a minimal `PATH`.
63const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
64
65/// How often the background task recomputes the tray menu snapshot off the main
66/// thread. The macOS tray polls `menu()` ~1 Hz; caching a couple of seconds keeps
67/// the displayed branch/sync state fresh without ever doing git I/O on the GUI
68/// thread (which would peg a core and stall shutdown — the #1186 regression).
69const MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
70
71/// A running background menu-refresh task and the token that stops it.
72struct RefreshTask {
73    /// Cancelled by `shutdown` to end the refresh loop.
74    token: CancellationToken,
75    /// The spawned loop, awaited on shutdown so it fully unwinds.
76    handle: JoinHandle<()>,
77}
78
79/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
80pub struct WorktreesService {
81    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
82    /// the background menu-refresh task can read it off the main thread.
83    registry: Arc<WorktreesRegistry>,
84    /// The most recent tray menu snapshot, recomputed off the main thread by
85    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
86    /// of this so it never blocks on git enrichment. `None` until the first
87    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
88    /// which case `menu()` falls back to a one-off inline compute.
89    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
90    /// The background refresh task, once started (`None` in tests / no runtime).
91    refresh: Mutex<Option<RefreshTask>>,
92    /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
93    /// through, so N open windows perform **one** `build_tree` per tick instead
94    /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
95    /// one cache. The one-shot `tree` op deliberately bypasses it and computes
96    /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
97    tree_cache: Arc<TreeSnapshotCache>,
98}
99
100impl WorktreesService {
101    /// Creates the service with an empty registry. Cheap — no I/O and no task;
102    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
103    /// off-thread menu caching, while tests use the bare service (menu computed
104    /// inline on demand).
105    #[must_use]
106    pub fn new() -> Self {
107        let registry = Arc::new(WorktreesRegistry::new());
108        Self {
109            registry: registry.clone(),
110            menu_cache: Arc::new(Mutex::new(None)),
111            refresh: Mutex::new(None),
112            tree_cache: Arc::new(TreeSnapshotCache::new(registry)),
113        }
114    }
115
116    /// Starts the background task that recomputes the tray menu snapshot every
117    /// [`MENU_REFRESH_INTERVAL`] **off the main thread** — git enrichment is
118    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
119    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
120    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
121    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
122    /// keep computing the menu inline.
123    pub fn start_menu_refresh(&self) {
124        if tokio::runtime::Handle::try_current().is_err() {
125            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
126            return;
127        }
128        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
129        if guard.is_some() {
130            return;
131        }
132        let token = CancellationToken::new();
133        let loop_token = token.clone();
134        let registry = self.registry.clone();
135        let cache = self.menu_cache.clone();
136        let handle = tokio::spawn(async move {
137            loop {
138                // Snapshot the registry (a cheap lock), then build the menu —
139                // which opens repos and parses git config — on a blocking thread,
140                // never on this async worker or the tray's main thread.
141                let entries = registry.list();
142                if let Ok(items) =
143                    tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
144                {
145                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
146                }
147                tokio::select! {
148                    () = loop_token.cancelled() => break,
149                    () = tokio::time::sleep(MENU_REFRESH_INTERVAL) => {}
150                }
151            }
152        });
153        *guard = Some(RefreshTask { token, handle });
154    }
155
156    /// Handles the `close` op: close a worktree's window and, for a **linked**
157    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
158    ///
159    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
160    ///   [`git_safety`] check returning the risks of deleting, so the extension can
161    ///   show a modal confirm only when something would actually be lost.
162    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
163    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
164    ///   worktree. The main working tree is refused defensively.
165    ///
166    /// Cross-window signalling (another window has the target open) is a
167    /// fast-follow: this core handles the **no-window** and **self-close**
168    /// (`requester_key == target_key`) cases, and errors clearly when another
169    /// window owns the target so the destructive path is never taken blind.
170    async fn close(&self, req: CloseRequest) -> Result<Value> {
171        // Which live windows currently have the target open. The canonical-path
172        // compare is disk I/O, so run it (with the safety check below) on a
173        // blocking thread, never under the registry lock or on the async worker.
174        let entries = self.registry.list();
175        let scan_path = req.path.clone();
176        let open_windows =
177            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
178                .await
179                .unwrap_or_default();
180        let open = !open_windows.is_empty();
181        let window_key = open_windows.first().map(|(k, _)| k.clone());
182        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
183
184        // Phase 1: the safety check runs only for a delete request awaiting
185        // confirmation. A "Close Window" (remove:false) never inspects git and
186        // has nothing to confirm, so it skips straight to execute.
187        if req.remove && !req.confirmed {
188            let path = req.path.clone();
189            let git = tokio::task::spawn_blocking(move || git_safety(&path))
190                .await
191                .map_err(|e| anyhow!("safety check task panicked: {e}"))??;
192            return Ok(serde_json::to_value(SafetyReport {
193                removable: git.removable,
194                is_main: git.is_main,
195                open,
196                window_key,
197                window_folder_count,
198                risks: git.risks,
199                info: git.info,
200            })
201            .unwrap_or_else(|_| json!({})));
202        }
203
204        // Phase 2: execute. Signal every owning window *other than the
205        // requester* (which closes itself on our `ok:true` reply, avoiding the
206        // ext-host-dies-mid-op race) and wait for each to unregister before
207        // touching the worktree. The directive reaches a cross-window target via
208        // its heartbeat reply — the only channel the daemon has to a window it
209        // can reply to but never call.
210        let others: Vec<String> = open_windows
211            .iter()
212            .map(|(k, _)| k.clone())
213            .filter(|k| req.requester_key.as_deref() != Some(k))
214            .collect();
215        for key in &others {
216            self.registry.mark_close_pending(key);
217        }
218        if !others.is_empty() {
219            await_windows_closed(
220                &self.registry,
221                &req.path,
222                req.requester_key.as_deref(),
223                CLOSE_WAIT_TIMEOUT,
224                CLOSE_WAIT_POLL,
225            )
226            .await?;
227        }
228
229        if req.remove {
230            let path = req.path.clone();
231            tokio::task::spawn_blocking(move || remove_worktree(&path))
232                .await
233                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
234            Ok(json!({ "removed": true }))
235        } else {
236            // "Close Window" with no owning window is a no-op success; a
237            // self-close replies and the extension closes its own window.
238            Ok(json!({ "closed": true }))
239        }
240    }
241}
242
243impl Default for WorktreesService {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249#[async_trait]
250impl DaemonService for WorktreesService {
251    fn name(&self) -> &'static str {
252        SERVICE_NAME
253    }
254
255    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
256        match op {
257            "register" => {
258                let req: RegisterRequest =
259                    serde_json::from_value(payload).context("invalid `register` payload")?;
260                if req.key.trim().is_empty() {
261                    bail!("`register` requires a non-empty `key`");
262                }
263                self.registry.register(req);
264                Ok(json!({ "ok": true }))
265            }
266            "heartbeat" => {
267                let key = require_str(&payload, "key", "heartbeat")?;
268                let known = self.registry.heartbeat(key);
269                // A pending close directive (#1277) rides the reply as an
270                // additive `close` field, taken-and-cleared here so it fires
271                // exactly once. Omitted when false to keep older windows — which
272                // read only `known` — byte-identical on the wire.
273                let mut reply = json!({ "known": known });
274                if self.registry.take_close_pending(key) {
275                    reply["close"] = Value::Bool(true);
276                }
277                Ok(reply)
278            }
279            "unregister" => {
280                let key = require_str(&payload, "key", "unregister")?;
281                Ok(json!({ "removed": self.registry.unregister(key) }))
282            }
283            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
284            "tree" => {
285                // The same `{ repos, show_closed }` snapshot the `subscribe`
286                // stream pushes, so a one-shot `tree` fetch and the live stream
287                // agree byte-for-byte (the git enumeration runs off-lock on a
288                // blocking thread inside the helper). Computed fresh here — the
289                // `tree` op is a rare manual refresh, deliberately bypassing the
290                // stream's coalescing cache so it never returns a stale view
291                // (#1303).
292                Ok(tree_snapshot(&self.registry).await)
293            }
294            "ahead-behind" => {
295                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
296                // snapshot no longer carries ahead/behind — the dominant
297                // per-worktree cost when computed eagerly every tick — so a client
298                // (the extension on expand, `worktrees tree`) asks for it here only
299                // for the worktrees it is about to show. Batched by path, one op per
300                // repo expand; the git walks run on a blocking thread.
301                let paths = payload
302                    .get("paths")
303                    .and_then(Value::as_array)
304                    .map(|arr| {
305                        arr.iter()
306                            .filter_map(Value::as_str)
307                            .map(PathBuf::from)
308                            .collect::<Vec<_>>()
309                    })
310                    .unwrap_or_default();
311                Ok(json!({ "results": ahead_behind_results(paths).await }))
312            }
313            "set-show-closed" => {
314                // The daemon-backed show/hide-closed toggle (#1301). Setting it
315                // bumps the change-notify, so every subscribed window re-pushes a
316                // snapshot carrying the new `show_closed` — reliable cross-window
317                // sync `context.globalState` could not do.
318                let show_closed = payload
319                    .get("show_closed")
320                    .and_then(Value::as_bool)
321                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
322                self.registry.set_show_closed(show_closed);
323                Ok(json!({ "ok": true }))
324            }
325            "open" => {
326                // Focus (or open — VS Code reuses an already-open window) an
327                // arbitrary worktree folder supplied by a socket client, reusing
328                // the tray's launcher path: `focus_window` resolves the launcher
329                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
330                // the absolute-existing-directory guard (which also blocks a
331                // `-`-leading path being parsed by `code` as a flag). This is the
332                // one op a socket *writer* can use to spawn `code`; see the
333                // ADR-0040 threat model (#1266).
334                let path = require_str(&payload, "path", "open")?;
335                focus_window(Path::new(path))?;
336                Ok(json!({ "ok": true }))
337            }
338            "close" => {
339                // Close a worktree's window and (for a linked worktree)
340                // **delete** it. Destructive, so all git logic stays in the
341                // daemon (git2, never a shell) and the main working tree is
342                // refused defensively — the UI gating is not the only guard.
343                // See ADR-0049 and docs/worktrees-service.md.
344                let req: CloseRequest =
345                    serde_json::from_value(payload).context("invalid `close` payload")?;
346                self.close(req).await
347            }
348            other => bail!("unknown worktrees op: {other}"),
349        }
350    }
351
352    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
353        // The single streaming op: a live push of the repo/worktree `tree`
354        // snapshot. Every other op falls through to the request→reply `handle`.
355        if op != "subscribe" {
356            return None;
357        }
358        Some(Box::new(WorktreesStream {
359            // Every stream reads through the one shared cache, so N windows
360            // sampling the same tick build the tree once, not N times (#1303).
361            cache: self.tree_cache.clone(),
362            // Capture the change source *now* — before the server takes its
363            // initial snapshot — so a change racing that snapshot still wakes us.
364            changes: self.registry.subscribe_changes(),
365        }))
366    }
367
368    fn menu(&self) -> MenuSnapshot {
369        // Serve the snapshot the background task maintains off the main thread;
370        // fall back to a one-off inline compute only before the first refresh
371        // lands (or with no runtime — the unit tests). Never blocks on git here
372        // in the daemon, honouring the trait's "cheap, must not block" contract.
373        let cached = self
374            .menu_cache
375            .lock()
376            .unwrap_or_else(PoisonError::into_inner)
377            .clone();
378        let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
379        MenuSnapshot {
380            title: SUBMENU_TITLE.to_string(),
381            items,
382        }
383    }
384
385    async fn menu_action(&self, action_id: &str) -> Result<()> {
386        if let Some(key) = action_id.strip_prefix("focus:") {
387            // The registry resolves the folder under its own lock and clones it
388            // out, so the mutex is never held across the process launch.
389            let folder = self
390                .registry
391                .first_folder(key)
392                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
393            focus_window(&folder)?;
394            return Ok(());
395        }
396        bail!("unknown worktrees menu action: {action_id}")
397    }
398
399    async fn status(&self) -> ServiceStatus {
400        let entries = self.registry.list();
401        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
402        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
403        let windows = enriched_windows(entries).await;
404        ServiceStatus {
405            name: SERVICE_NAME.to_string(),
406            healthy: true,
407            summary,
408            detail: json!({ "windows": windows }),
409        }
410    }
411
412    async fn shutdown(&self) {
413        // Stop the background menu-refresh task; the registry itself is in-memory
414        // with nothing to drain or persist. Take the task out from under the lock
415        // first so the `std::Mutex` is never held across the `.await`.
416        let task = self
417            .refresh
418            .lock()
419            .unwrap_or_else(PoisonError::into_inner)
420            .take();
421        if let Some(task) = task {
422            task.token.cancel();
423            let _ = task.handle.await;
424        }
425    }
426}
427
428/// Extracts a required string `field` from an op payload, erroring with the op
429/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
430/// (`key`) and `open` (`path`).
431fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
432    payload
433        .get(field)
434        .and_then(Value::as_str)
435        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
436}
437
438/// The live git state of a worktree folder: the checked-out branch and how far
439/// it has diverged from its upstream. Computed on read from the on-disk repo
440/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
441/// snapshot taken at registration.
442///
443/// Every field is optional and degrades independently: a folder that is not a
444/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
445/// listed — just without the fields it cannot supply. The `skip_serializing_if`
446/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
447/// omitting each absent field on the wire.
448#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
449struct GitStatus {
450    /// The checked-out branch, or `None` when detached or not in a repo.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    branch: Option<String>,
453    /// Commits the branch is ahead of its upstream (`None` without an upstream).
454    #[serde(skip_serializing_if = "Option::is_none")]
455    ahead: Option<usize>,
456    /// Commits the branch is behind its upstream (`None` without an upstream).
457    #[serde(skip_serializing_if = "Option::is_none")]
458    behind: Option<usize>,
459    /// The main repository's directory name — the parent repo for a linked
460    /// worktree, the checkout's own directory otherwise. Derived from git's
461    /// common dir so a worktree names the repo it belongs to rather than its
462    /// worktree-folder basename. `None` when not in a repo.
463    #[serde(skip_serializing_if = "Option::is_none")]
464    main_repo: Option<String>,
465    /// Whether the enriched folder is a **linked** git worktree rather than the
466    /// repository's main working tree. Omitted (false) for a normal checkout.
467    #[serde(skip_serializing_if = "is_false")]
468    is_worktree: bool,
469}
470
471/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
472/// field is dropped on the wire unless set — keeping older clients byte-identical
473/// (the protocol's forward-compatibility convention).
474#[allow(clippy::trivially_copy_pass_by_ref)]
475fn is_false(b: &bool) -> bool {
476    !*b
477}
478
479/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
480/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
481/// op and the tray menu, both bounded to the (few) open windows, where the extra
482/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
483/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
484fn git_status(folder: &Path) -> GitStatus {
485    git_status_impl(folder, true)
486}
487
488/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
489/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
490/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
491/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
492/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
493/// fields stay `None`, so they are omitted on the wire exactly as for a branch
494/// with no upstream.
495fn git_status_cheap(folder: &Path) -> GitStatus {
496    git_status_impl(folder, false)
497}
498
499/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
500/// repository that contains `folder` — so a subdirectory or a linked worktree both
501/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
502/// divergence. Every failure mode degrades to an empty status rather than
503/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
504fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
505    let Ok(repo) = Repository::discover(folder) else {
506        return GitStatus::default();
507    };
508    // Repo identity applies even when HEAD is unborn or detached, so a worktree
509    // still names its parent repo (and is flagged as a worktree) in those states.
510    let base = GitStatus {
511        main_repo: main_repo_name(repo.commondir()),
512        is_worktree: repo.is_worktree(),
513        ..GitStatus::default()
514    };
515    let Ok(head) = repo.head() else {
516        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
517        return base;
518    };
519    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
520    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
521    // name — degrades to no branch through this one path.
522    let Some(name) = head
523        .shorthand()
524        .ok()
525        .filter(|_| head.is_branch())
526        .map(str::to_string)
527    else {
528        return base;
529    };
530    // The divergence walk is the dominant per-worktree cost, so the cheap path
531    // skips it and leaves ahead/behind absent.
532    let (ahead, behind) = if with_ahead_behind {
533        let branch = git2::Branch::wrap(head);
534        match upstream_ahead_behind(&repo, &branch) {
535            Some((ahead, behind)) => (Some(ahead), Some(behind)),
536            None => (None, None),
537        }
538    } else {
539        (None, None)
540    };
541    GitStatus {
542        branch: Some(name),
543        ahead,
544        behind,
545        ..base
546    }
547}
548
549/// The ahead/behind divergence of `folder`'s checked-out branch versus its
550/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
551/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
552/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
553/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
554/// sync indicator.
555fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
556    let repo = Repository::discover(folder).ok()?;
557    let head = repo.head().ok()?;
558    if !head.is_branch() {
559        return None;
560    }
561    let branch = git2::Branch::wrap(head);
562    upstream_ahead_behind(&repo, &branch)
563}
564
565/// The main repository's directory name from git's common dir. For the usual
566/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
567/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
568/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
569/// no name can be derived.
570fn main_repo_name(commondir: &Path) -> Option<String> {
571    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
572    if file_name == ".git" {
573        // Normal layout: the repo is the directory that contains `.git`.
574        commondir
575            .parent()
576            .and_then(Path::file_name)
577            .map(|n| n.to_string_lossy().into_owned())
578    } else {
579        // A bare repo: use its own directory name, without any `.git` suffix.
580        Some(
581            file_name
582                .strip_suffix(".git")
583                .unwrap_or(&file_name)
584                .to_string(),
585        )
586    }
587}
588
589/// Ahead/behind commit counts of `branch` versus its configured upstream, or
590/// `None` when the branch tracks no upstream (or either tip is unresolvable).
591fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
592    let upstream = branch.upstream().ok()?;
593    let local_oid = branch.get().target()?;
594    let upstream_oid = upstream.get().target()?;
595    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
596}
597
598/// The wire shape of an enriched window: the stored entry fields plus the
599/// daemon-computed git state, flattened into one JSON object. Serializing
600/// through a single struct (rather than mutating a `Value`) keeps every present
601/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
602/// the absent git fields — no manual per-field insertion.
603#[derive(Serialize)]
604struct EnrichedEntry<'a> {
605    #[serde(flatten)]
606    entry: &'a WindowEntry,
607    #[serde(flatten)]
608    git: GitStatus,
609}
610
611/// Serializes a registry entry and folds in the live [`git_status`] of its
612/// primary (first) folder, producing the JSON object served on the wire
613/// (`list`/`status`) and read by the extension UI. Only the primary folder is
614/// enriched — it is the one the table shows and the "focus" action opens.
615fn enriched_entry(entry: &WindowEntry) -> Value {
616    let git = entry
617        .folders
618        .first()
619        .map(|folder| git_status(folder))
620        .unwrap_or_default();
621    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
622}
623
624/// Enriches a batch of entries with their git state on a blocking thread, since
625/// `git2` does synchronous disk I/O and this runs inside the async control-socket
626/// handler. A join failure degrades to an empty list rather than erroring.
627async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
628    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
629        .await
630        .unwrap_or_default()
631}
632
633// --- Repo/worktree tree (#1265) ----------------------------------------------
634
635/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
636/// Present on a repo in the `tree` payload only for `github.com` remotes; a
637/// non-GitHub (or remote-less) repo omits it.
638#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
639struct GithubIdentity {
640    /// The repository owner (user or org) — the first path segment.
641    owner: String,
642    /// The repository name, with any `.git` suffix stripped.
643    name: String,
644}
645
646/// One worktree of a repository in the `tree` payload: its path, live git state,
647/// whether it is the main working tree, and whether a VS Code window currently
648/// has it open (with that window's key, for the focus action). Optional git
649/// fields degrade independently, exactly like [`GitStatus`].
650///
651/// Ahead/behind divergence is deliberately **absent** from this snapshot: it was
652/// the dominant per-worktree cost when computed eagerly for every worktree on
653/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
654/// worktrees a client actually shows (#1306).
655#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
656struct TreeWorktree {
657    /// Absolute path to the worktree's working directory.
658    path: String,
659    /// The checked-out branch, or `None` when detached or unborn.
660    #[serde(skip_serializing_if = "Option::is_none")]
661    branch: Option<String>,
662    /// Whether this is the repository's main working tree (vs a linked worktree).
663    is_main: bool,
664    /// Whether a live VS Code window currently has this worktree open.
665    open: bool,
666    /// The open window's registry key, when `open` — the handle a focus action
667    /// resolves. Absent for a worktree with no open window.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    window_key: Option<String>,
670}
671
672/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
673/// derived from the distinct open windows; a repo leaves the tree when its last
674/// window closes (the open-window-derived model, ADR-0040 / #1264).
675#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
676struct TreeRepo {
677    /// The main repository's directory name (see [`main_repo_name`]).
678    main_repo: String,
679    /// The GitHub identity of `origin`, when it is a `github.com` remote.
680    #[serde(skip_serializing_if = "Option::is_none")]
681    github: Option<GithubIdentity>,
682    /// Absolute path to the main working tree — the repo's root.
683    root: String,
684    /// Every worktree of the repo: the main working tree first, then linked
685    /// worktrees sorted by path.
686    worktrees: Vec<TreeWorktree>,
687}
688
689/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
690/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
691/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
692/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
693/// slashes are stripped; anything with an empty or extra path segment is
694/// rejected (best-effort, never panics).
695fn github_identity(url: &str) -> Option<GithubIdentity> {
696    let url = url.trim();
697    // Reduce every supported form to the `owner/name…` tail after the host.
698    let rest = [
699        "https://github.com/",
700        "http://github.com/",
701        "ssh://git@github.com/",
702        "git://github.com/",
703        "git@github.com:",
704    ]
705    .iter()
706    .find_map(|prefix| url.strip_prefix(prefix))?;
707    let rest = rest.strip_suffix(".git").unwrap_or(rest);
708    let rest = rest.trim_end_matches('/');
709    let mut parts = rest.splitn(2, '/');
710    let owner = parts.next()?.trim();
711    let name = parts.next()?.trim();
712    // A well-formed identity has exactly two non-empty segments.
713    if owner.is_empty() || name.is_empty() || name.contains('/') {
714        return None;
715    }
716    Some(GithubIdentity {
717        owner: owner.to_string(),
718        name: name.to_string(),
719    })
720}
721
722/// The GitHub identity of `repo`: `origin`'s URL first, else the first
723/// `github.com` remote found. `None` when no remote is a GitHub remote.
724fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
725    if let Ok(origin) = repo.find_remote("origin") {
726        if let Some(id) = origin.url().ok().and_then(github_identity) {
727            return Some(id);
728        }
729    }
730    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
731    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
732    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
733    let names = repo.remotes().ok();
734    names
735        .iter()
736        .flat_map(|arr| arr.iter())
737        .flatten()
738        .flatten()
739        .filter_map(|name| repo.find_remote(name).ok())
740        .find_map(|remote| remote.url().ok().and_then(github_identity))
741}
742
743/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
744/// falling back to the path as-given when it cannot be canonicalized (e.g. it
745/// no longer exists) so the join still degrades gracefully.
746fn canonical(path: &Path) -> PathBuf {
747    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
748}
749
750/// Indexes the open windows by canonicalized workspace-folder path → window key,
751/// so a worktree path can be joined back to the window (if any) that has it open.
752/// The first window wins a shared folder; `entries` arrive in a deterministic
753/// (repo, key) order, so the choice is stable.
754fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
755    let mut index = HashMap::new();
756    for entry in entries {
757        for folder in &entry.folders {
758            index
759                .entry(canonical(folder))
760                .or_insert_with(|| entry.key.clone());
761        }
762    }
763    index
764}
765
766/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
767/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
768/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
769/// by the caller from the enumeration (main working tree vs linked).
770fn worktree_entry(
771    path: &Path,
772    is_main: bool,
773    open_index: &HashMap<PathBuf, String>,
774) -> TreeWorktree {
775    let status = git_status_cheap(path);
776    let window_key = open_index.get(&canonical(path)).cloned();
777    TreeWorktree {
778        path: path.display().to_string(),
779        branch: status.branch,
780        is_main,
781        open: window_key.is_some(),
782        window_key,
783    }
784}
785
786/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
787/// handle discovered from one of its folders. Opens the **main** repo from the
788/// shared common dir's parent so the main working tree and every linked worktree
789/// are enumerated regardless of which one seeded the discovery. `None` for a
790/// bare or otherwise root-less repo (no working tree to show).
791fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
792    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
793    // linked worktrees; its parent is the main working tree.
794    let commondir = canonical(discovered.commondir());
795    let main_root = commondir.parent()?.to_path_buf();
796    let main_repo = Repository::open(&main_root).ok()?;
797
798    // Main working tree first.
799    let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
800    // Then every linked worktree, sorted by path for deterministic output. The
801    // `StringArray` of names is bound so `iter()` can borrow it (only
802    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
803    // worktree is skipped.
804    let names = main_repo.worktrees().ok();
805    let mut linked: Vec<PathBuf> = names
806        .iter()
807        .flat_map(|arr| arr.iter())
808        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
809        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
810        .filter_map(|name| main_repo.find_worktree(name).ok())
811        .map(|wt| wt.path().to_path_buf())
812        .collect();
813    linked.sort();
814    worktrees.extend(
815        linked
816            .iter()
817            .map(|path| worktree_entry(path, false, open_index)),
818    );
819
820    Some(TreeRepo {
821        main_repo: main_repo_name(&commondir)?,
822        github: remote_github_identity(&main_repo),
823        root: main_root.display().to_string(),
824        worktrees,
825    })
826}
827
828/// Resolves the seed `folders` to their distinct repositories and enumerates
829/// each repo's worktrees. Dedupes repos by their common dir (shared across a
830/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
831/// is not in a git repo is skipped. Pure blocking git I/O — call it via
832/// [`tree_repos`], never under the registry lock.
833fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
834    let open_index = open_window_index(&windows);
835    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
836    for folder in &folders {
837        let Ok(repo) = Repository::discover(folder) else {
838            continue;
839        };
840        let key = canonical(repo.commondir());
841        if repos.contains_key(&key) {
842            continue;
843        }
844        if let Some(tree) = repo_tree(&repo, &open_index) {
845            repos.insert(key, tree);
846        }
847    }
848    repos.into_values().collect()
849}
850
851/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
852/// does synchronous disk I/O and this runs inside the async control-socket
853/// handler), returning the serialized `repos` array. A join failure degrades to
854/// an empty list rather than erroring, matching [`enriched_windows`].
855async fn tree_repos(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<Value> {
856    tokio::task::spawn_blocking(move || {
857        build_tree(folders, windows)
858            .iter()
859            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
860            .collect()
861    })
862    .await
863    .unwrap_or_default()
864}
865
866// --- Lazy ahead/behind (#1306) -----------------------------------------------
867
868/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
869/// returning a JSON object keyed by the **requested** path string:
870/// `{ "<path>": { "ahead": n, "behind": m }, … }`. A path with no upstream (or that
871/// is not a repo / is detached) is **omitted** — the client renders it without a
872/// sync indicator, exactly as the tree does for an absent `ahead`/`behind`.
873///
874/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
875/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
876/// shows (the extension on expand), not for every worktree on every tick. The git
877/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
878/// degrades to an empty object rather than erroring.
879async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
880    tokio::task::spawn_blocking(move || {
881        let mut results = serde_json::Map::new();
882        for path in paths {
883            if let Some((ahead, behind)) = folder_ahead_behind(&path) {
884                results.insert(
885                    path.display().to_string(),
886                    json!({ "ahead": ahead, "behind": behind }),
887                );
888            }
889        }
890        Value::Object(results)
891    })
892    .await
893    .unwrap_or_else(|_| json!({}))
894}
895
896// --- Push subscription (#1267) -----------------------------------------------
897
898/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
899/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
900/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
901/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
902/// type only has to (a) relay the registry's change-notify and (b) read the
903/// tree snapshot on demand.
904///
905/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
906/// is built at most once per tick and fanned out, rather than each stream
907/// rebuilding the identical tree. This type holds only cheap handles — a clone
908/// of the shared cache and its own change-notify receiver.
909struct WorktreesStream {
910    /// The shared coalescing cache the snapshot is read through, so every
911    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
912    cache: Arc<TreeSnapshotCache>,
913    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
914    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
915    /// server's diff drops any snapshot that ends up identical.
916    changes: watch::Receiver<u64>,
917}
918
919#[async_trait]
920impl ServiceStream for WorktreesStream {
921    async fn changed(&mut self) {
922        // `watch::Receiver::changed` marks the newest version seen, so a burst of
923        // bumps collapses into a single wakeup. If every sender is gone (the
924        // registry — and thus the daemon — is tearing down) it returns `Err`;
925        // park instead of returning, so this arm can never spin the server's
926        // `select!` (the tick and shutdown arms still drive teardown).
927        if self.changes.changed().await.is_err() {
928            std::future::pending::<()>().await;
929        }
930    }
931
932    async fn snapshot(&self) -> Value {
933        // Read through the shared coalescing cache. The value is built by the
934        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
935        // live push agree byte-for-byte — but here it is built once per tick and
936        // shared across every subscriber rather than rebuilt per stream (#1303).
937        self.cache.snapshot().await
938    }
939}
940
941/// A coalescing cache for the global tree snapshot (#1303).
942///
943/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
944/// server re-samples each on its own `STREAM_TICK` and on every registry change
945/// — so with N windows the *identical* global tree was being built N times per
946/// tick. This cache collapses that to **one** build: all streams share it, and
947/// it rebuilds at most once per `ttl` (the stream tick) per registry
948/// change-generation.
949///
950/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
951/// exactly as before:
952/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
953///   still matches — a `register`/`unregister`/toggle bumps it and forces a
954///   fresh build, so subscribers never see a stale visible set; and
955/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
956///   branch switch, new commits), which fires no registry event, still surfaces
957///   within one tick.
958///
959/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
960/// callers, so a burst of N streams waking on the same tick/change performs one
961/// build while the rest wait and read the shared result. The one-shot `tree` op
962/// bypasses this and computes fresh — it is a rare manual refresh, not part of
963/// the per-tick fan-out.
964struct TreeSnapshotCache {
965    /// The registry every snapshot is built from, and whose change-generation
966    /// gates cache reuse.
967    registry: Arc<WorktreesRegistry>,
968    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
969    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
970    /// coalesced build runs at most once per tick; tests inject a shorter value.
971    ttl: Duration,
972    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
973    /// because it is deliberately held across the `.await` of the git
974    /// enumeration, so concurrent callers serialize onto one build rather than
975    /// each computing their own.
976    state: AsyncMutex<Option<CachedTree>>,
977    /// How many times the tree was actually (re)built — so tests can assert the
978    /// coalescing collapses an N-stream burst into one build. Cheap and always
979    /// maintained; only read under `#[cfg(test)]`.
980    computes: AtomicU64,
981}
982
983/// One cached tree snapshot: the shared value plus the two freshness stamps
984/// [`TreeSnapshotCache`] checks before reusing it.
985struct CachedTree {
986    /// The registry change-generation captured *before* the build, so a change
987    /// racing the build advances the generation and the next read rebuilds
988    /// (conservative: it may rebuild once needlessly, but never serves stale).
989    generation: u64,
990    /// When the value was built, for the `ttl` staleness check.
991    computed_at: Instant,
992    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
993    /// subscriber by cloning the `Arc`'s inner value.
994    value: Arc<Value>,
995}
996
997impl TreeSnapshotCache {
998    /// Creates a cache over `registry` with the default TTL — the server's
999    /// [`STREAM_TICK`](crate::daemon::server), so the coalesced build runs at
1000    /// most once per tick.
1001    fn new(registry: Arc<WorktreesRegistry>) -> Self {
1002        Self::with_ttl(registry, crate::daemon::server::STREAM_TICK)
1003    }
1004
1005    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
1006    /// long) freshness window without waiting a real tick.
1007    fn with_ttl(registry: Arc<WorktreesRegistry>, ttl: Duration) -> Self {
1008        Self {
1009            registry,
1010            ttl,
1011            state: AsyncMutex::new(None),
1012            computes: AtomicU64::new(0),
1013        }
1014    }
1015
1016    /// The current tree snapshot, built at most once per `ttl` per registry
1017    /// change-generation and shared across all callers. See the type docs for
1018    /// the freshness and single-flight semantics.
1019    async fn snapshot(&self) -> Value {
1020        // Hold the lock across the whole check-and-build so concurrent callers
1021        // serialize onto one build (single-flight); reading the generation here
1022        // (before the build) means a change racing the build forces the *next*
1023        // read to rebuild rather than serving this now-stale value.
1024        let mut state = self.state.lock().await;
1025        let generation = self.registry.change_generation();
1026        // Reuse the cached value only while it matches the current generation
1027        // *and* is within the TTL; either failing forces a rebuild.
1028        let fresh = state.as_ref().and_then(|cached| {
1029            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
1030                .then(|| Arc::clone(&cached.value))
1031        });
1032        let value = if let Some(value) = fresh {
1033            value
1034        } else {
1035            let value = Arc::new(tree_snapshot(&self.registry).await);
1036            self.computes.fetch_add(1, Ordering::Relaxed);
1037            *state = Some(CachedTree {
1038                generation,
1039                computed_at: Instant::now(),
1040                value: Arc::clone(&value),
1041            });
1042            value
1043        };
1044        // Release the lock before the (deeper) clone of the shared value out.
1045        drop(state);
1046        (*value).clone()
1047    }
1048
1049    /// How many times the tree was actually built — the coalescing assertion in
1050    /// tests (N reads within one tick/generation should build once).
1051    #[cfg(test)]
1052    fn compute_count(&self) -> u64 {
1053        self.computes.load(Ordering::Relaxed)
1054    }
1055}
1056
1057/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
1058/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
1059/// (the seed folders to derive repos from, and the live windows to join on) and
1060/// a lock-free read of the toggle, then the git enumeration/enrichment off the
1061/// lock on a blocking thread inside [`tree_repos`].
1062async fn tree_snapshot(registry: &WorktreesRegistry) -> Value {
1063    let folders = registry.open_folders();
1064    let windows = registry.list();
1065    let show_closed = registry.show_closed();
1066    json!({ "repos": tree_repos(folders, windows).await, "show_closed": show_closed })
1067}
1068
1069/// A short human name for a window: its repo, else its first folder's basename,
1070/// else a placeholder.
1071fn display_name(entry: &WindowEntry) -> String {
1072    if let Some(repo) = &entry.repo {
1073        return repo.clone();
1074    }
1075    if let Some(folder) = entry.folders.first() {
1076        return folder.file_name().map_or_else(
1077            || folder.display().to_string(),
1078            |n| n.to_string_lossy().into_owned(),
1079        );
1080    }
1081    "(no folder)".to_string()
1082}
1083
1084/// Separator between the repo name and branch for a normal working tree.
1085const REPO_SEP: char = '·';
1086/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
1087/// line is distinguishable at a glance from its parent repo's main checkout.
1088const WORKTREE_SEP: char = '⑂';
1089
1090/// The full tray item list for a window set: the "No open windows" placeholder
1091/// when empty, else one line per window via [`window_menu_items`]. Does the git
1092/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
1093/// background refresh task — and inline only as a cold-start fallback in `menu`.
1094fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
1095    if entries.is_empty() {
1096        vec![MenuItem::Label("No open windows".to_string())]
1097    } else {
1098        window_menu_items(entries)
1099    }
1100}
1101
1102/// Builds the tray items for a non-empty window list: **one clickable line per
1103/// window** whose label carries the live git state and whose click focuses that
1104/// window. A window with no workspace folder has nothing for `code` to open, so
1105/// it stays a non-clickable status line. The labels read each worktree from disk
1106/// (via [`window_label`]) — cheap for a realistic window count and consistent
1107/// with reap-on-read.
1108fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
1109    entries
1110        .iter()
1111        .map(|entry| {
1112            let label = window_label(entry);
1113            if entry.folders.is_empty() {
1114                MenuItem::Label(label)
1115            } else {
1116                MenuItem::Action(MenuAction {
1117                    id: format!("focus:{}", entry.key),
1118                    label,
1119                    enabled: true,
1120                })
1121            }
1122        })
1123        .collect()
1124}
1125
1126/// The tray label for one window: the **main repository** name, then live branch
1127/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
1128/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
1129/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
1130/// that is not a repo falls back to its reported title.
1131fn window_label(entry: &WindowEntry) -> String {
1132    let status = entry
1133        .folders
1134        .first()
1135        .map(|folder| git_status(folder))
1136        .unwrap_or_default();
1137    // Prefer the git-derived main repo so a linked worktree names its parent
1138    // repository rather than its worktree-folder basename.
1139    let name = status
1140        .main_repo
1141        .clone()
1142        .unwrap_or_else(|| display_name(entry));
1143    if let Some(branch) = &status.branch {
1144        let sep = if status.is_worktree {
1145            WORKTREE_SEP
1146        } else {
1147            REPO_SEP
1148        };
1149        return match sync_indicator(status.ahead, status.behind) {
1150            Some(sync) => format!("{name} {sep} {branch} {sync}"),
1151            None => format!("{name} {sep} {branch}"),
1152        };
1153    }
1154    // No git branch (not a repo / detached): fall back to the reported title.
1155    match &entry.title {
1156        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
1157        _ => name,
1158    }
1159}
1160
1161/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
1162/// has no upstream to compare against.
1163fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
1164    match (ahead, behind) {
1165        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
1166        _ => None,
1167    }
1168}
1169
1170/// Well-known absolute locations for the VS Code launcher, tried in order so a
1171/// daemon running under launchd (with a minimal `PATH`) still finds it.
1172const CODE_BINARY_CANDIDATES: &[&str] = &[
1173    "/usr/local/bin/code",
1174    "/opt/homebrew/bin/code",
1175    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
1176    "/usr/bin/code",
1177];
1178
1179/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
1180/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`].
1181fn focus_window(folder: &Path) -> Result<()> {
1182    focus_window_with(&resolve_code_binary(), folder)
1183}
1184
1185/// Spawns `program` on `folder` after validating the folder. Split out from
1186/// [`focus_window`] so the validation and spawn paths are testable with an
1187/// explicit launcher (no environment or installed-editor dependency).
1188///
1189/// Best-effort and non-blocking: the spawned child is reaped on a detached
1190/// thread so a long-lived daemon does not accumulate zombies one per focus.
1191fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
1192    // The tray path passes an absolute workspace folder, but the socket `open`
1193    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
1194    // real check there, not just an assertion: requiring an absolute path also
1195    // rules out a `-`-leading path being parsed by `code` as a flag.
1196    if !folder.is_absolute() {
1197        bail!(
1198            "refusing to focus a non-absolute folder path: {}",
1199            folder.display()
1200        );
1201    }
1202    if !folder.is_dir() {
1203        bail!("worktree folder no longer exists: {}", folder.display());
1204    }
1205    // Detach the launcher's stdio so its output never interleaves into the
1206    // long-lived daemon's own stdout/stderr (or the test harness's).
1207    let child = Command::new(program)
1208        .arg(folder)
1209        .stdin(Stdio::null())
1210        .stdout(Stdio::null())
1211        .stderr(Stdio::null())
1212        .spawn()
1213        .with_context(|| {
1214            format!(
1215                "failed to launch `{}` to focus {}",
1216                program.display(),
1217                folder.display()
1218            )
1219        })?;
1220    // Reap the child without blocking so it never lingers as a zombie.
1221    std::thread::spawn(move || {
1222        let mut child = child;
1223        let _ = child.wait();
1224    });
1225    Ok(())
1226}
1227
1228/// Resolves the VS Code launcher from the real environment: the
1229/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
1230/// `code` on `PATH`. The pure resolution logic lives in
1231/// [`resolve_code_binary_from`] for testing.
1232fn resolve_code_binary() -> PathBuf {
1233    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
1234}
1235
1236/// Pure launcher resolution: `env_override` wins; otherwise the first existing
1237/// `candidate`; otherwise bare `code`.
1238fn resolve_code_binary_from(
1239    env_override: Option<std::ffi::OsString>,
1240    candidates: &[&str],
1241) -> PathBuf {
1242    if let Some(path) = env_override {
1243        return PathBuf::from(path);
1244    }
1245    for candidate in candidates {
1246        let path = Path::new(candidate);
1247        if path.exists() {
1248            return path.to_path_buf();
1249        }
1250    }
1251    PathBuf::from("code")
1252}
1253
1254// --- Close op (#1277) --------------------------------------------------------
1255
1256/// The `close` op payload: close a worktree's window and (for a linked worktree)
1257/// delete it. Symmetric to `open`, but destructive, so it carries the
1258/// two-phase-confirm and self-close routing fields.
1259#[derive(Debug, Clone, Deserialize)]
1260struct CloseRequest {
1261    /// Absolute path of the target worktree's working directory.
1262    path: PathBuf,
1263    /// The requesting window's key, so a self-close (`requester_key` owns the
1264    /// target) removes-then-replies and lets the extension close its own window,
1265    /// rather than waiting on a window that is blocked awaiting this reply.
1266    #[serde(default)]
1267    requester_key: Option<String>,
1268    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
1269    /// only close its window (main "Close Window"). A delete is refused on the
1270    /// main working tree regardless of this flag.
1271    #[serde(default)]
1272    remove: bool,
1273    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
1274    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
1275    #[serde(default)]
1276    confirmed: bool,
1277}
1278
1279/// One risk or informational note in a [`SafetyReport`]: a machine-readable
1280/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
1281/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
1282/// commits that survive because the branch is kept).
1283#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1284struct Note {
1285    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
1286    kind: String,
1287    /// A human-readable one-line explanation for the confirm dialog.
1288    detail: String,
1289}
1290
1291impl Note {
1292    fn new(kind: &str, detail: impl Into<String>) -> Self {
1293        Self {
1294            kind: kind.to_string(),
1295            detail: detail.into(),
1296        }
1297    }
1298}
1299
1300/// The phase-1 safety report the extension reads to decide whether to prompt.
1301/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
1302/// entry → show a modal confirm listing them.
1303#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1304struct SafetyReport {
1305    /// Whether the target is a deletable (linked) worktree at all — `false` for
1306    /// the main working tree, which the daemon never removes.
1307    removable: bool,
1308    /// Whether the target is the repository's main working tree.
1309    is_main: bool,
1310    /// Whether a live VS Code window currently has the target open.
1311    open: bool,
1312    /// The owning window's key, when `open` (the first, for the wait/close).
1313    #[serde(skip_serializing_if = "Option::is_none")]
1314    window_key: Option<String>,
1315    /// How many workspace folders the owning window has — so the extension can
1316    /// warn "this window has N folders open; all will close" (failure mode #10).
1317    window_folder_count: usize,
1318    /// Conditions that would lose data on removal; a non-empty list forces a
1319    /// confirm dialog.
1320    risks: Vec<Note>,
1321    /// Non-blocking context shown for awareness (e.g. unpushed commits that
1322    /// survive because the branch is kept).
1323    info: Vec<Note>,
1324}
1325
1326/// The git-only half of the safety check, before the registry's open-window
1327/// facts are folded in. Pure disk I/O; computed on a blocking thread.
1328#[derive(Debug, Clone, PartialEq, Eq)]
1329struct GitSafety {
1330    is_main: bool,
1331    removable: bool,
1332    risks: Vec<Note>,
1333    info: Vec<Note>,
1334}
1335
1336/// Live windows (key, workspace-folder count) that currently have `path` open,
1337/// matched by canonicalized path so a symlinked or `..`-laden report still
1338/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
1339fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
1340    let target = canonical(path);
1341    entries
1342        .iter()
1343        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
1344        .map(|e| (e.key.clone(), e.folders.len()))
1345        .collect()
1346}
1347
1348/// How long the execute phase waits for a signalled window to close
1349/// (`unregister`) before giving up. Deliberately generous against the ~10s
1350/// heartbeat interval the close directive rides — a window may have just
1351/// heartbeated, so the directive is only picked up on the *next* one — plus the
1352/// window's own close/save latency. The keyed-push responsiveness upgrade
1353/// (#1277 fast-follow) removes this wait entirely.
1354const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
1355
1356/// How often the execute phase re-checks whether the signalled windows have
1357/// unregistered.
1358const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
1359
1360/// Waits up to `timeout` for every window *other than* `requester` that has
1361/// `path` open to unregister (close), polling the live registry every `poll`.
1362/// A window whose `last_seen` has already gone stale is reaped by `list()` and
1363/// so counts as closed. Returns an error naming the still-open windows on
1364/// timeout, so the caller can surface "window did not close" and leave the
1365/// worktree untouched (failure modes #4/#5).
1366async fn await_windows_closed(
1367    registry: &WorktreesRegistry,
1368    path: &Path,
1369    requester: Option<&str>,
1370    timeout: Duration,
1371    poll: Duration,
1372) -> Result<()> {
1373    let deadline = std::time::Instant::now() + timeout;
1374    loop {
1375        // The registry read is cheap CPU, but the path canonicalization in
1376        // `windows_with_path` is disk I/O — do the whole check on a blocking
1377        // thread, never on the async worker.
1378        let entries = registry.list();
1379        let path = path.to_path_buf();
1380        let requester = requester.map(str::to_string);
1381        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
1382            windows_with_path(&entries, &path)
1383                .into_iter()
1384                .map(|(k, _)| k)
1385                .filter(|k| requester.as_deref() != Some(k))
1386                .collect()
1387        })
1388        .await
1389        .unwrap_or_default();
1390
1391        if remaining.is_empty() {
1392            return Ok(());
1393        }
1394        if std::time::Instant::now() >= deadline {
1395            bail!("window(s) did not close in time: {}", remaining.join(", "));
1396        }
1397        tokio::time::sleep(poll).await;
1398    }
1399}
1400
1401/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
1402/// working tree (never removable) and, for a linked worktree, what a removal
1403/// would lose. Best-effort per-check but the overall open must succeed — a path
1404/// that is not a git worktree is a hard error (we refuse to delete an unknown
1405/// directory). A path that no longer exists is treated as an already-removed
1406/// linked worktree so the idempotent execute path can proceed with no dialog.
1407fn git_safety(path: &Path) -> Result<GitSafety> {
1408    if !path.exists() {
1409        return Ok(GitSafety {
1410            is_main: false,
1411            removable: true,
1412            risks: vec![],
1413            info: vec![Note::new("already-removed", "worktree no longer exists")],
1414        });
1415    }
1416    let repo = Repository::open(path)
1417        .with_context(|| format!("not a git worktree: {}", path.display()))?;
1418    // The one structural fact deletability keys off — never the branch name.
1419    if !repo.is_worktree() {
1420        return Ok(GitSafety {
1421            is_main: true,
1422            removable: false,
1423            risks: vec![],
1424            info: vec![Note::new(
1425                "main-working-tree",
1426                "the repository's main working tree is never deleted",
1427            )],
1428        });
1429    }
1430
1431    let mut risks = Vec::new();
1432    let mut info = Vec::new();
1433
1434    let (dirty, untracked) = count_dirty_untracked(&repo);
1435    if dirty > 0 {
1436        risks.push(Note::new(
1437            "dirty",
1438            format!("{dirty} modified tracked file(s) would be lost"),
1439        ));
1440    }
1441    if untracked > 0 {
1442        risks.push(Note::new(
1443            "untracked",
1444            format!("{untracked} untracked file(s) would be lost"),
1445        ));
1446    }
1447
1448    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
1449    let state = repo.state();
1450    if state != RepositoryState::Clean {
1451        risks.push(Note::new(
1452            "in-progress",
1453            format!("an in-progress {state:?} operation would be lost"),
1454        ));
1455    }
1456
1457    // Commits reachable only from a detached HEAD are GC'd once the worktree —
1458    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
1459    // or tag) loses nothing, so it is not flagged.
1460    if repo.head_detached().unwrap_or(false) {
1461        let lost = unreachable_commit_count(&repo).unwrap_or(0);
1462        if lost > 0 {
1463            risks.push(Note::new(
1464                "unreachable-commits",
1465                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
1466            ));
1467        }
1468    }
1469
1470    // Unpushed commits on a *named* branch survive: removal never deletes the
1471    // branch. Informational only — it must not block or prompt.
1472    if let Some(ahead) = current_branch_ahead(&repo) {
1473        if ahead > 0 {
1474            info.push(Note::new(
1475                "unpushed",
1476                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
1477            ));
1478        }
1479    }
1480
1481    Ok(GitSafety {
1482        is_main: false,
1483        removable: true,
1484        risks,
1485        info,
1486    })
1487}
1488
1489/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
1490/// staged or unstaged modification (including conflicts and deletions);
1491/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
1492/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
1493/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
1494/// `(0, 0)` rather than sinking the whole safety check.
1495fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
1496    let mut opts = StatusOptions::new();
1497    opts.include_untracked(true)
1498        .recurse_untracked_dirs(true)
1499        .include_ignored(false)
1500        .exclude_submodules(true);
1501    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1502        return (0, 0);
1503    };
1504    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
1505    // it is deliberately excluded from this mask).
1506    let tracked = Status::INDEX_NEW
1507        | Status::INDEX_MODIFIED
1508        | Status::INDEX_DELETED
1509        | Status::INDEX_RENAMED
1510        | Status::INDEX_TYPECHANGE
1511        | Status::WT_MODIFIED
1512        | Status::WT_DELETED
1513        | Status::WT_TYPECHANGE
1514        | Status::WT_RENAMED
1515        | Status::CONFLICTED;
1516    let mut dirty = 0;
1517    let mut untracked = 0;
1518    for entry in statuses.iter() {
1519        let s = entry.status();
1520        if s.contains(Status::WT_NEW) {
1521            untracked += 1;
1522        }
1523        if s.intersects(tracked) {
1524            dirty += 1;
1525        }
1526    }
1527    (dirty, untracked)
1528}
1529
1530/// Counts commits reachable from the (detached) HEAD but from no other ref —
1531/// the commits git would garbage-collect once the worktree's HEAD is gone.
1532/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
1533/// skipped (hiding it would hide the very commits we are counting); every real
1534/// branch/tag/remote ref is hidden, so a tip that any branch also points at
1535/// yields `0` (nothing is actually lost).
1536fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
1537    let head_oid = repo.head().ok()?.target()?;
1538    let mut walk = repo.revwalk().ok()?;
1539    walk.push(head_oid).ok()?;
1540    for reference in repo.references().ok()? {
1541        let Ok(reference) = reference else { continue };
1542        // Skip the literal HEAD ref — hiding it would hide the very commits we
1543        // are counting; every real branch/tag/remote ref is hidden below.
1544        if matches!(reference.name(), Ok("HEAD")) {
1545            continue;
1546        }
1547        if let Some(oid) = reference.target() {
1548            let _ = walk.hide(oid);
1549        }
1550    }
1551    Some(walk.flatten().count())
1552}
1553
1554/// Commits the worktree's current branch is ahead of its upstream, or `None`
1555/// when HEAD is detached or the branch tracks no upstream. Reuses
1556/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
1557fn current_branch_ahead(repo: &Repository) -> Option<usize> {
1558    let head = repo.head().ok()?;
1559    if !head.is_branch() {
1560        return None;
1561    }
1562    let branch = git2::Branch::wrap(head);
1563    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
1564}
1565
1566/// Resolves the linked worktree whose working directory canonicalizes to
1567/// `target` to its registered name in `main_repo`. Errors when `target` is not
1568/// one of the repo's worktrees — the defensive guard against removing a path
1569/// that opened as a worktree but is not enumerated. Split out so that guard is
1570/// unit-testable without corrupting git's worktree admin state.
1571fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
1572    let names = main_repo.worktrees()?;
1573    names
1574        .iter()
1575        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
1576        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
1577        .find(|name| {
1578            main_repo
1579                .find_worktree(name)
1580                .is_ok_and(|wt| canonical(wt.path()) == target)
1581        })
1582        .map(str::to_string)
1583        .ok_or_else(|| {
1584            anyhow!(
1585                "worktree {} is not registered in {}",
1586                target.display(),
1587                main_repo.path().display()
1588            )
1589        })
1590}
1591
1592/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
1593/// daemon-`PATH` problem the launcher fights): deletes both the admin files and
1594/// the checked-out directory. Refuses the main working tree (the defensive
1595/// backstop behind the UI gating) and a locked worktree (surfacing "unlock
1596/// first" rather than forcing past the lock). Idempotent: an already-removed
1597/// path is a success.
1598fn remove_worktree(path: &Path) -> Result<()> {
1599    if !path.exists() {
1600        return Ok(());
1601    }
1602    let repo = Repository::open(path)
1603        .with_context(|| format!("not a git worktree: {}", path.display()))?;
1604    if !repo.is_worktree() {
1605        bail!(
1606            "refusing to delete the main working tree: {}",
1607            path.display()
1608        );
1609    }
1610    // The Worktree handle lives on the *main* repo (the common dir's parent),
1611    // keyed by name; find it by matching the target path.
1612    let commondir = canonical(repo.commondir());
1613    let main_root = commondir
1614        .parent()
1615        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
1616        .to_path_buf();
1617    // Drop the worktree-scoped handle before pruning deletes its directory.
1618    drop(repo);
1619    let main_repo = Repository::open(&main_root)
1620        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
1621    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
1622    let worktree = main_repo.find_worktree(&name)?;
1623
1624    // Never silently force past a lock (failure mode #6).
1625    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
1626        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
1627        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
1628    }
1629
1630    // valid(true): prune even though the worktree still exists on disk;
1631    // working_tree(true): also recursively delete the checked-out directory.
1632    // locked stays false, so a lock (re-checked above) is never forced.
1633    let mut opts = git2::WorktreePruneOptions::new();
1634    opts.valid(true).working_tree(true);
1635    worktree
1636        .prune(Some(&mut opts))
1637        .with_context(|| format!("failed to remove worktree {}", path.display()))?;
1638    Ok(())
1639}
1640
1641#[cfg(test)]
1642#[allow(clippy::unwrap_used, clippy::expect_used)]
1643mod tests {
1644    use super::*;
1645    use chrono::Utc;
1646
1647    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
1648        json!({
1649            "key": key,
1650            "folders": [folder],
1651            "repo": repo,
1652            "title": format!("{key}-title"),
1653            "pid": 1234,
1654        })
1655    }
1656
1657    /// Pulls the `windows` array out of a `list`/`status` payload.
1658    fn windows_of(payload: &Value) -> &Vec<Value> {
1659        payload
1660            .get("windows")
1661            .and_then(Value::as_array)
1662            .expect("windows array")
1663    }
1664
1665    #[tokio::test]
1666    async fn name_and_unknown_op() {
1667        let svc = WorktreesService::new();
1668        assert_eq!(svc.name(), "worktrees");
1669        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
1670    }
1671
1672    #[tokio::test]
1673    async fn handle_routes_ops_and_shapes_payloads() {
1674        let svc = WorktreesService::new();
1675        // Empty to start.
1676        let payload = svc.handle("list", Value::Null).await.unwrap();
1677        assert_eq!(payload, json!({ "windows": [] }));
1678
1679        // register → { ok: true }, then it shows up in list.
1680        let reply = svc
1681            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1682            .await
1683            .unwrap();
1684        assert_eq!(reply, json!({ "ok": true }));
1685        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
1686        assert_eq!(windows.len(), 1);
1687        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
1688        assert!(windows[0].get("last_seen").is_some());
1689
1690        // heartbeat known/unknown.
1691        let known = svc
1692            .handle("heartbeat", json!({ "key": "w1" }))
1693            .await
1694            .unwrap();
1695        assert_eq!(known, json!({ "known": true }));
1696        let unknown = svc
1697            .handle("heartbeat", json!({ "key": "nope" }))
1698            .await
1699            .unwrap();
1700        assert_eq!(unknown, json!({ "known": false }));
1701
1702        // unregister removes, then repeats as a no-op success.
1703        let gone = svc
1704            .handle("unregister", json!({ "key": "w1" }))
1705            .await
1706            .unwrap();
1707        assert_eq!(gone, json!({ "removed": true }));
1708        let again = svc
1709            .handle("unregister", json!({ "key": "w1" }))
1710            .await
1711            .unwrap();
1712        assert_eq!(again, json!({ "removed": false }));
1713    }
1714
1715    #[tokio::test]
1716    async fn handle_rejects_missing_or_empty_key() {
1717        let svc = WorktreesService::new();
1718        // register validates a present, non-blank key.
1719        assert!(svc.handle("register", json!({})).await.is_err());
1720        assert!(svc
1721            .handle("register", json!({ "key": "  " }))
1722            .await
1723            .is_err());
1724        // heartbeat/unregister require the key via `require_str`.
1725        assert!(svc.handle("heartbeat", json!({})).await.is_err());
1726        assert!(svc.handle("unregister", json!({})).await.is_err());
1727    }
1728
1729    #[test]
1730    fn display_name_prefers_repo_then_folder_basename() {
1731        let base = WindowEntry {
1732            key: "k".to_string(),
1733            folders: vec![PathBuf::from("/home/me/project")],
1734            repo: Some("my-repo".to_string()),
1735            title: None,
1736            pid: None,
1737            last_seen: Utc::now(),
1738        };
1739        assert_eq!(display_name(&base), "my-repo");
1740
1741        let no_repo = WindowEntry {
1742            repo: None,
1743            ..base.clone()
1744        };
1745        assert_eq!(display_name(&no_repo), "project");
1746
1747        let nothing = WindowEntry {
1748            repo: None,
1749            folders: vec![],
1750            ..base.clone()
1751        };
1752        assert_eq!(display_name(&nothing), "(no folder)");
1753
1754        // A folder with no basename (the filesystem root) falls back to its
1755        // displayed path rather than panicking or yielding an empty name.
1756        let rootish = WindowEntry {
1757            repo: None,
1758            folders: vec![PathBuf::from("/")],
1759            ..base
1760        };
1761        assert_eq!(display_name(&rootish), "/");
1762    }
1763
1764    #[test]
1765    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
1766        let now = Utc::now();
1767        let entries = vec![
1768            // A folderless window has nothing to focus, so it stays a plain
1769            // Label; a title equal to the name collapses to just the name. It
1770            // leads the list so the focus-action lookup below is exercised
1771            // against a leading non-Action item it has to skip.
1772            WindowEntry {
1773                key: "k2".to_string(),
1774                folders: vec![],
1775                repo: Some("solo".to_string()),
1776                title: Some("solo".to_string()),
1777                pid: None,
1778                last_seen: now,
1779            },
1780            // A folder-bearing, non-repo window: one clickable Action whose label
1781            // is the stats line ("name · title", since /tmp is not a git repo).
1782            WindowEntry {
1783                key: "k1".to_string(),
1784                folders: vec![PathBuf::from("/tmp/a")],
1785                repo: Some("repo".to_string()),
1786                title: Some("a branch".to_string()),
1787                pid: None,
1788                last_seen: now,
1789            },
1790        ];
1791        let items = window_menu_items(&entries);
1792        // Exactly one item per window — no duplicate label, no separator.
1793        assert_eq!(items.len(), 2);
1794        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
1795
1796        // The folder-bearing window is a single clickable action carrying the
1797        // stats label (the old label + Focus action, merged).
1798        let action = items
1799            .iter()
1800            .find_map(|i| match i {
1801                MenuItem::Action(a) => Some(a),
1802                _ => None,
1803            })
1804            .expect("a focus action");
1805        assert_eq!(action.id, "focus:k1");
1806        assert_eq!(action.label, "repo · a branch");
1807
1808        // The folderless window is a non-clickable label (not "solo · solo").
1809        let labels: Vec<&str> = items
1810            .iter()
1811            .filter_map(|i| match i {
1812                MenuItem::Label(t) => Some(t.as_str()),
1813                _ => None,
1814            })
1815            .collect();
1816        assert_eq!(labels, vec!["solo"]);
1817    }
1818
1819    #[tokio::test]
1820    async fn menu_and_status_shapes() {
1821        let svc = WorktreesService::new();
1822        // Empty.
1823        let menu = svc.menu();
1824        assert_eq!(menu.title, "Worktrees");
1825        assert!(matches!(
1826            menu.items.first(),
1827            Some(MenuItem::Label(text)) if text == "No open windows"
1828        ));
1829        let status = svc.status().await;
1830        assert_eq!(status.name, "worktrees");
1831        assert!(status.healthy);
1832        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
1833
1834        // Two folder-bearing windows in the same repo, plus one folderless
1835        // window that shares the repo but has nothing for `code` to open.
1836        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1837            .await
1838            .unwrap();
1839        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
1840            .await
1841            .unwrap();
1842        svc.handle(
1843            "register",
1844            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
1845        )
1846        .await
1847        .unwrap();
1848        let status = svc.status().await;
1849        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
1850
1851        let menu = svc.menu();
1852        // One line per window — no separator, no duplicate label.
1853        assert_eq!(menu.items.len(), 3);
1854        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
1855        let action_ids: Vec<&str> = menu
1856            .items
1857            .iter()
1858            .filter_map(|i| match i {
1859                MenuItem::Action(a) => Some(a.id.as_str()),
1860                _ => None,
1861            })
1862            .collect();
1863        // The two folder-bearing windows are clickable; the folderless one is a
1864        // plain Label, so it never yields a focus action.
1865        assert!(action_ids.contains(&"focus:w1"));
1866        assert!(action_ids.contains(&"focus:w2"));
1867        assert!(!action_ids.contains(&"focus:w3"));
1868    }
1869
1870    #[test]
1871    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
1872        // With no tokio runtime, the background task is never spawned, so the
1873        // bare service keeps computing `menu()` inline (what the tests rely on).
1874        let svc = WorktreesService::new();
1875        svc.start_menu_refresh();
1876        assert!(svc.refresh.lock().unwrap().is_none());
1877    }
1878
1879    #[tokio::test]
1880    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
1881        let svc = WorktreesService::new();
1882        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
1883            .await
1884            .unwrap();
1885        // Before the task runs, `menu()` computes inline from an empty cache.
1886        assert!(svc.menu_cache.lock().unwrap().is_none());
1887
1888        svc.start_menu_refresh();
1889        // Idempotent: a second call does not start a second task.
1890        svc.start_menu_refresh();
1891
1892        // The task fills the cache off the main thread; poll briefly for it.
1893        let mut filled = false;
1894        for _ in 0..100 {
1895            if svc.menu_cache.lock().unwrap().is_some() {
1896                filled = true;
1897                break;
1898            }
1899            tokio::time::sleep(Duration::from_millis(10)).await;
1900        }
1901        assert!(filled, "background refresh should populate the menu cache");
1902
1903        // `menu()` now serves the cache: one clickable line for the window.
1904        let menu = svc.menu();
1905        assert_eq!(menu.title, "Worktrees");
1906        assert!(menu
1907            .items
1908            .iter()
1909            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
1910
1911        // Shutdown cancels and joins the task, clearing the handle.
1912        svc.shutdown().await;
1913        assert!(svc.refresh.lock().unwrap().is_none());
1914    }
1915
1916    #[tokio::test]
1917    async fn default_constructs_an_empty_service() {
1918        let svc = WorktreesService::default();
1919        let payload = svc.handle("list", Value::Null).await.unwrap();
1920        assert_eq!(payload, json!({ "windows": [] }));
1921    }
1922
1923    // --- Push subscription (#1267) -----------------------------------------
1924
1925    #[tokio::test]
1926    async fn subscribe_streams_only_for_the_subscribe_op() {
1927        let svc = WorktreesService::new();
1928        // The one streaming op yields a stream; every other op (including the
1929        // request/reply worktrees ops) declines, so the server dispatches them
1930        // normally.
1931        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
1932        assert!(svc.subscribe("list", &Value::Null).is_none());
1933        assert!(svc.subscribe("register", &Value::Null).is_none());
1934        assert!(svc.subscribe("bogus", &Value::Null).is_none());
1935    }
1936
1937    #[tokio::test]
1938    async fn subscribe_snapshot_matches_the_tree_op() {
1939        let dir = tempfile::tempdir().unwrap();
1940        let repo = init_repo(dir.path());
1941        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1942        repo.set_head("refs/heads/main").unwrap();
1943
1944        let svc = WorktreesService::new();
1945        let stream = svc
1946            .subscribe("subscribe", &Value::Null)
1947            .expect("subscribe stream");
1948        // No windows yet → no repos derived; the toggle rides along at its
1949        // default (show all).
1950        assert_eq!(
1951            stream.snapshot().await,
1952            json!({ "repos": [], "show_closed": true })
1953        );
1954
1955        // A window opens on the repo → the snapshot carries it, byte-identical to
1956        // what the `tree` op returns for the same registry state.
1957        svc.handle(
1958            "register",
1959            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1960        )
1961        .await
1962        .unwrap();
1963        let snap = stream.snapshot().await;
1964        let tree = svc.handle("tree", Value::Null).await.unwrap();
1965        assert_eq!(snap, tree);
1966        let repos = snap["repos"].as_array().expect("repos array");
1967        assert_eq!(repos.len(), 1);
1968        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
1969    }
1970
1971    #[tokio::test]
1972    async fn subscribe_changed_wakes_on_register() {
1973        let svc = WorktreesService::new();
1974        let mut stream = svc
1975            .subscribe("subscribe", &Value::Null)
1976            .expect("subscribe stream");
1977        // Idle: `changed()` must not resolve without a registry change.
1978        tokio::select! {
1979            () = stream.changed() => panic!("changed resolved with no registry change"),
1980            () = tokio::time::sleep(Duration::from_millis(50)) => {}
1981        }
1982        // A register bumps the change-notify → `changed()` resolves promptly.
1983        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
1984            .await
1985            .unwrap();
1986        tokio::time::timeout(Duration::from_secs(1), stream.changed())
1987            .await
1988            .expect("changed should resolve after a register");
1989    }
1990
1991    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
1992
1993    #[tokio::test]
1994    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
1995        let reg = Arc::new(WorktreesRegistry::new());
1996        // A long TTL so only the generation gate is exercised here.
1997        let cache = TreeSnapshotCache::with_ttl(reg, Duration::from_secs(60));
1998        // The first read builds once.
1999        let first = cache.snapshot().await;
2000        assert_eq!(cache.compute_count(), 1);
2001        // Further reads with no registry change and within the TTL reuse the
2002        // cached value — no extra build, byte-identical result.
2003        let second = cache.snapshot().await;
2004        assert_eq!(
2005            cache.compute_count(),
2006            1,
2007            "an unchanged read must not rebuild"
2008        );
2009        assert_eq!(first, second);
2010    }
2011
2012    #[tokio::test]
2013    async fn tree_cache_single_flights_a_read_burst() {
2014        let reg = Arc::new(WorktreesRegistry::new());
2015        let cache = Arc::new(TreeSnapshotCache::with_ttl(reg, Duration::from_secs(60)));
2016        // A burst of concurrent readers — as N subscriber streams would wake
2017        // together on a change/tick — collapses to exactly one build; the rest
2018        // read the shared result (the acceptance criterion).
2019        let mut handles = Vec::new();
2020        for _ in 0..16 {
2021            let cache = cache.clone();
2022            handles.push(tokio::spawn(async move { cache.snapshot().await }));
2023        }
2024        let mut results = Vec::new();
2025        for handle in handles {
2026            results.push(handle.await.unwrap());
2027        }
2028        assert_eq!(
2029            cache.compute_count(),
2030            1,
2031            "a concurrent read burst must build the tree once"
2032        );
2033        assert!(
2034            results.windows(2).all(|w| w[0] == w[1]),
2035            "every reader must observe the identical snapshot"
2036        );
2037    }
2038
2039    #[tokio::test]
2040    async fn tree_cache_rebuilds_on_registry_change() {
2041        let reg = Arc::new(WorktreesRegistry::new());
2042        let cache = TreeSnapshotCache::with_ttl(reg.clone(), Duration::from_secs(60));
2043        cache.snapshot().await;
2044        assert_eq!(cache.compute_count(), 1);
2045        // A registry change bumps the generation, so the next read rebuilds even
2046        // though the (long) TTL has not expired — subscribers never see a stale
2047        // visible set.
2048        assert!(reg.set_show_closed(false));
2049        cache.snapshot().await;
2050        assert_eq!(
2051            cache.compute_count(),
2052            2,
2053            "a generation bump must force a rebuild"
2054        );
2055    }
2056
2057    #[tokio::test]
2058    async fn tree_cache_rebuilds_after_ttl_expiry() {
2059        let reg = Arc::new(WorktreesRegistry::new());
2060        // A zero TTL: every read is already past it, so a pure on-disk git change
2061        // still surfaces on the next tick with no registry bump needed.
2062        let cache = TreeSnapshotCache::with_ttl(reg, Duration::ZERO);
2063        cache.snapshot().await;
2064        cache.snapshot().await;
2065        assert_eq!(
2066            cache.compute_count(),
2067            2,
2068            "an expired TTL must force a rebuild each read"
2069        );
2070    }
2071
2072    #[tokio::test]
2073    async fn subscribe_streams_share_one_build_per_generation() {
2074        let svc = WorktreesService::new();
2075        let s1 = svc
2076            .subscribe("subscribe", &Value::Null)
2077            .expect("subscribe stream");
2078        let s2 = svc
2079            .subscribe("subscribe", &Value::Null)
2080            .expect("subscribe stream");
2081        // Two windows' streams sampling the same registry state build the tree
2082        // once, not once per stream (#1303) — they share the service's cache.
2083        let a = s1.snapshot().await;
2084        let b = s2.snapshot().await;
2085        assert_eq!(a, b);
2086        assert_eq!(
2087            svc.tree_cache.compute_count(),
2088            1,
2089            "N streams on one generation must share a single build"
2090        );
2091    }
2092
2093    // --- Show/hide-closed toggle (#1301) -----------------------------------
2094
2095    #[tokio::test]
2096    async fn set_show_closed_toggles_the_snapshot_field() {
2097        let svc = WorktreesService::new();
2098        // The snapshot carries the toggle; it defaults to show-all.
2099        assert_eq!(
2100            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2101            json!(true)
2102        );
2103        // Setting it flips the field the next snapshot reports.
2104        let reply = svc
2105            .handle("set-show-closed", json!({ "show_closed": false }))
2106            .await
2107            .unwrap();
2108        assert_eq!(reply, json!({ "ok": true }));
2109        assert_eq!(
2110            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2111            json!(false)
2112        );
2113    }
2114
2115    #[tokio::test]
2116    async fn set_show_closed_rejects_a_non_boolean_payload() {
2117        let svc = WorktreesService::new();
2118        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
2119        assert!(svc
2120            .handle("set-show-closed", json!({ "show_closed": "yes" }))
2121            .await
2122            .is_err());
2123    }
2124
2125    #[tokio::test]
2126    async fn set_show_closed_wakes_the_subscription() {
2127        let svc = WorktreesService::new();
2128        let mut stream = svc
2129            .subscribe("subscribe", &Value::Null)
2130            .expect("subscribe stream");
2131        // A real flip bumps the change-notify → `changed()` resolves promptly.
2132        svc.handle("set-show-closed", json!({ "show_closed": false }))
2133            .await
2134            .unwrap();
2135        tokio::time::timeout(Duration::from_secs(1), stream.changed())
2136            .await
2137            .expect("changed should resolve after a toggle flip");
2138        // The pushed snapshot now reflects the new toggle.
2139        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
2140    }
2141
2142    #[tokio::test]
2143    async fn menu_action_rejects_unknown_and_missing_window() {
2144        let svc = WorktreesService::new();
2145        assert!(svc.menu_action("bogus").await.is_err());
2146        // A focus for a key with no registration errors rather than spawning.
2147        assert!(svc.menu_action("focus:nope").await.is_err());
2148        svc.shutdown().await;
2149    }
2150
2151    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
2152    /// variable (via `resolve_code_binary` → `focus_window`) —
2153    /// `menu_action_focus_resolves_folder_and_spawns` and
2154    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
2155    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
2156    /// transient overlap under the harness's test parallelism is benign.
2157    struct VscodeBinGuard(Option<std::ffi::OsString>);
2158    impl Drop for VscodeBinGuard {
2159        fn drop(&mut self) {
2160            match self.0.take() {
2161                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
2162                None => std::env::remove_var(VSCODE_BIN_ENV),
2163            }
2164        }
2165    }
2166
2167    #[tokio::test]
2168    async fn menu_action_focus_resolves_folder_and_spawns() {
2169        let dir = tempfile::tempdir().unwrap();
2170        let svc = WorktreesService::new();
2171        svc.handle(
2172            "register",
2173            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2174        )
2175        .await
2176        .unwrap();
2177
2178        // Point the launcher at a harmless binary so the spawn deterministically
2179        // succeeds and the focus path returns Ok.
2180        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2181        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2182        svc.menu_action("focus:w1").await.unwrap();
2183    }
2184
2185    #[tokio::test]
2186    async fn open_rejects_missing_relative_or_nonexistent_path() {
2187        let svc = WorktreesService::new();
2188        // A missing `path` is a payload error.
2189        assert!(svc.handle("open", json!({})).await.is_err());
2190        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
2191        // A relative path is rejected before any spawn — this is also what
2192        // blocks a `-`-leading argument from reaching `code` as a flag.
2193        assert!(svc
2194            .handle("open", json!({ "path": "relative/dir" }))
2195            .await
2196            .is_err());
2197        assert!(svc
2198            .handle("open", json!({ "path": "-flag" }))
2199            .await
2200            .is_err());
2201        // An absolute path that does not exist is rejected before any spawn, so
2202        // no launcher is needed for these guard cases.
2203        assert!(svc
2204            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
2205            .await
2206            .is_err());
2207        svc.shutdown().await;
2208    }
2209
2210    #[tokio::test]
2211    async fn open_focuses_an_existing_absolute_dir() {
2212        let dir = tempfile::tempdir().unwrap();
2213        let svc = WorktreesService::new();
2214        // Pin the launcher to a harmless binary so the spawn deterministically
2215        // succeeds whether or not `code` is installed. Unlike the tray `focus`
2216        // path, `open` takes the folder straight from the payload — no prior
2217        // `register` is required.
2218        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2219        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2220        let reply = svc
2221            .handle("open", json!({ "path": dir.path() }))
2222            .await
2223            .unwrap();
2224        assert_eq!(reply, json!({ "ok": true }));
2225        svc.shutdown().await;
2226    }
2227
2228    #[test]
2229    fn focus_window_with_validates_folder_then_spawns() {
2230        let dir = tempfile::tempdir().unwrap();
2231        // Non-absolute and missing-directory folders are rejected before spawn.
2232        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
2233        assert!(
2234            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
2235        );
2236        // A valid absolute directory spawns the launcher successfully.
2237        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
2238        // A missing launcher surfaces the spawn error (with context), not Ok.
2239        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
2240    }
2241
2242    #[test]
2243    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
2244        // Env override wins outright.
2245        assert_eq!(
2246            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
2247            PathBuf::from("/custom/code")
2248        );
2249        // No override: the first existing candidate is chosen.
2250        let existing = tempfile::NamedTempFile::new().unwrap();
2251        let existing_path = existing.path().to_str().unwrap();
2252        assert_eq!(
2253            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
2254            PathBuf::from(existing_path)
2255        );
2256        // Nothing exists: fall back to bare `code` on PATH.
2257        assert_eq!(
2258            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
2259            PathBuf::from("code")
2260        );
2261        // The real-env wrapper resolves without panicking.
2262        let _ = resolve_code_binary();
2263    }
2264
2265    // --- Git enrichment (#1186) --------------------------------------------
2266
2267    /// Initializes a fresh repo with a deterministic identity so `commit()`
2268    /// works without depending on a global git config.
2269    fn init_repo(dir: &Path) -> Repository {
2270        let repo = Repository::init(dir).unwrap();
2271        let mut cfg = repo.config().unwrap();
2272        cfg.set_str("user.name", "Test").unwrap();
2273        cfg.set_str("user.email", "test@example.com").unwrap();
2274        repo
2275    }
2276
2277    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
2278    /// optionally moving `refname` to it, and returns its oid.
2279    fn empty_commit(
2280        repo: &Repository,
2281        refname: Option<&str>,
2282        parents: &[&git2::Commit<'_>],
2283        msg: &str,
2284    ) -> git2::Oid {
2285        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2286        let tree = repo
2287            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
2288            .unwrap();
2289        repo.commit(refname, &sig, &sig, msg, &tree, parents)
2290            .unwrap()
2291    }
2292
2293    /// Commits `content` as file `name` onto `refname`, chaining off its current
2294    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
2295    /// the file is checked out into a worktree and can then be modified to
2296    /// produce a dirty (tracked) status.
2297    fn commit_file(
2298        repo: &Repository,
2299        refname: &str,
2300        name: &str,
2301        content: &[u8],
2302        msg: &str,
2303    ) -> git2::Oid {
2304        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2305        let blob = repo.blob(content).unwrap();
2306        let mut builder = repo.treebuilder(None).unwrap();
2307        builder.insert(name, blob, 0o100_644).unwrap();
2308        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
2309        let parent = repo
2310            .refname_to_id(refname)
2311            .ok()
2312            .and_then(|oid| repo.find_commit(oid).ok());
2313        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
2314        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
2315            .unwrap()
2316    }
2317
2318    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
2319    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
2320    fn diverging_repo(dir: &Path) -> Repository {
2321        let repo = init_repo(dir);
2322        // A: the shared base on `main`.
2323        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2324        let a_commit = repo.find_commit(a).unwrap();
2325        // origin/main diverges to C, a sibling of the local tip.
2326        let c = empty_commit(&repo, None, &[&a_commit], "C");
2327        repo.reference("refs/remotes/origin/main", c, true, "origin main")
2328            .unwrap();
2329        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
2330        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
2331        // Release the commit's borrow of `repo` so it can be returned.
2332        drop(a_commit);
2333        repo.set_head("refs/heads/main").unwrap();
2334        // Configure the tracking relationship so `upstream()` resolves.
2335        let mut cfg = repo.config().unwrap();
2336        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
2337            .unwrap();
2338        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
2339            .unwrap();
2340        cfg.set_str("branch.main.remote", "origin").unwrap();
2341        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
2342        repo
2343    }
2344
2345    #[test]
2346    fn git_status_reads_branch_and_ahead_behind() {
2347        let dir = tempfile::tempdir().unwrap();
2348        let _repo = diverging_repo(dir.path());
2349        let status = git_status(dir.path());
2350        assert_eq!(status.branch.as_deref(), Some("main"));
2351        assert_eq!(status.ahead, Some(1));
2352        assert_eq!(status.behind, Some(1));
2353        // A normal checkout names itself and is not flagged a worktree.
2354        assert_eq!(
2355            status.main_repo.as_deref(),
2356            dir.path().file_name().and_then(|n| n.to_str())
2357        );
2358        assert!(!status.is_worktree);
2359    }
2360
2361    #[test]
2362    fn git_status_empty_repo_is_unborn() {
2363        // A repo with no commits has an unborn HEAD, so `head()` errors and the
2364        // branch/sync fields stay empty rather than panicking — but the repo
2365        // identity is still resolved from the common dir.
2366        let dir = tempfile::tempdir().unwrap();
2367        init_repo(dir.path());
2368        let status = git_status(dir.path());
2369        assert_eq!(status.branch, None);
2370        assert_eq!(status.ahead, None);
2371        assert_eq!(status.behind, None);
2372        assert_eq!(
2373            status.main_repo.as_deref(),
2374            dir.path().file_name().and_then(|n| n.to_str())
2375        );
2376        assert!(!status.is_worktree);
2377    }
2378
2379    #[test]
2380    fn git_status_no_upstream_reports_branch_only() {
2381        let dir = tempfile::tempdir().unwrap();
2382        let repo = init_repo(dir.path());
2383        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2384        repo.set_head("refs/heads/main").unwrap();
2385        let status = git_status(dir.path());
2386        assert_eq!(status.branch.as_deref(), Some("main"));
2387        // No upstream → ahead/behind stay absent rather than zero.
2388        assert_eq!(status.ahead, None);
2389        assert_eq!(status.behind, None);
2390    }
2391
2392    #[test]
2393    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
2394        // A plain directory that is not a git repo yields nothing at all.
2395        let plain = tempfile::tempdir().unwrap();
2396        assert_eq!(git_status(plain.path()), GitStatus::default());
2397
2398        // A detached HEAD reports no branch (and thus no sync), but the repo
2399        // identity is still resolved from the common dir.
2400        let dir = tempfile::tempdir().unwrap();
2401        let repo = init_repo(dir.path());
2402        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2403        repo.set_head_detached(a).unwrap();
2404        let status = git_status(dir.path());
2405        assert_eq!(status.branch, None);
2406        assert_eq!(status.ahead, None);
2407        assert_eq!(status.behind, None);
2408        assert_eq!(
2409            status.main_repo.as_deref(),
2410            dir.path().file_name().and_then(|n| n.to_str())
2411        );
2412        assert!(!status.is_worktree);
2413    }
2414
2415    // --- Lazy ahead/behind (#1306) -----------------------------------------
2416
2417    #[test]
2418    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
2419        // The same repo `git_status` reports 1/1 for. The cheap variant used by
2420        // the streamed tree snapshot still reads the branch and repo identity, but
2421        // leaves ahead/behind absent — divergence is now lazy (#1306).
2422        let dir = tempfile::tempdir().unwrap();
2423        let _repo = diverging_repo(dir.path());
2424        let status = git_status_cheap(dir.path());
2425        assert_eq!(status.branch.as_deref(), Some("main"));
2426        assert_eq!(status.ahead, None);
2427        assert_eq!(status.behind, None);
2428        assert_eq!(
2429            status.main_repo.as_deref(),
2430            dir.path().file_name().and_then(|n| n.to_str())
2431        );
2432    }
2433
2434    #[test]
2435    fn folder_ahead_behind_computes_divergence_and_degrades() {
2436        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
2437        let dir = tempfile::tempdir().unwrap();
2438        let _repo = diverging_repo(dir.path());
2439        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
2440
2441        // A branch with no upstream → None (the tree renders no sync indicator).
2442        let no_up = tempfile::tempdir().unwrap();
2443        let repo = init_repo(no_up.path());
2444        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2445        repo.set_head("refs/heads/main").unwrap();
2446        assert_eq!(folder_ahead_behind(no_up.path()), None);
2447
2448        // A detached HEAD and a plain (non-repo) directory → None.
2449        let detached = tempfile::tempdir().unwrap();
2450        let drepo = init_repo(detached.path());
2451        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
2452        drepo.set_head_detached(a).unwrap();
2453        assert_eq!(folder_ahead_behind(detached.path()), None);
2454        let plain = tempfile::tempdir().unwrap();
2455        assert_eq!(folder_ahead_behind(plain.path()), None);
2456    }
2457
2458    #[tokio::test]
2459    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
2460        let diverging = tempfile::tempdir().unwrap();
2461        let _d = diverging_repo(diverging.path());
2462        let no_up = tempfile::tempdir().unwrap();
2463        let repo = init_repo(no_up.path());
2464        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2465        repo.set_head("refs/heads/main").unwrap();
2466
2467        let svc = WorktreesService::new();
2468        let diverging_path = diverging.path().display().to_string();
2469        let no_up_path = no_up.path().display().to_string();
2470        let reply = svc
2471            .handle(
2472                "ahead-behind",
2473                json!({ "paths": [&diverging_path, &no_up_path] }),
2474            )
2475            .await
2476            .unwrap();
2477        let results = reply.get("results").unwrap();
2478        // The diverging worktree carries its counts, keyed by the requested path.
2479        let d = results.get(diverging_path.as_str()).unwrap();
2480        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
2481        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
2482        // The no-upstream worktree is omitted entirely, not reported as zero.
2483        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
2484
2485        // A missing/empty `paths` list yields an empty results object, not an error.
2486        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
2487        assert_eq!(empty.get("results"), Some(&json!({})));
2488    }
2489
2490    #[tokio::test]
2491    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
2492        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
2493        let dir = tempfile::tempdir().unwrap();
2494        let _repo = diverging_repo(dir.path());
2495        let svc = WorktreesService::new();
2496        svc.handle(
2497            "register",
2498            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
2499        )
2500        .await
2501        .unwrap();
2502
2503        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2504        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
2505        let main_wt = &worktrees[0];
2506        // The cheap parts are present, but divergence is not — it is fetched
2507        // lazily via the `ahead-behind` op (#1306).
2508        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
2509        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
2510        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
2511    }
2512
2513    #[test]
2514    fn sync_indicator_formats_only_with_upstream() {
2515        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
2516        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
2517        assert_eq!(sync_indicator(None, None), None);
2518        // A partial pair (no real upstream) yields nothing.
2519        assert_eq!(sync_indicator(Some(1), None), None);
2520    }
2521
2522    #[tokio::test]
2523    async fn list_enriches_entries_with_git_status() {
2524        let dir = tempfile::tempdir().unwrap();
2525        let repo = init_repo(dir.path());
2526        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2527        repo.set_head("refs/heads/main").unwrap();
2528
2529        let svc = WorktreesService::new();
2530        svc.handle(
2531            "register",
2532            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2533        )
2534        .await
2535        .unwrap();
2536        let payload = svc.handle("list", Value::Null).await.unwrap();
2537        let windows = windows_of(&payload);
2538        assert_eq!(windows.len(), 1);
2539        assert_eq!(
2540            windows[0].get("branch").and_then(Value::as_str),
2541            Some("main")
2542        );
2543        // No upstream configured → the ahead/behind keys are absent, not zero.
2544        assert!(windows[0].get("ahead").is_none());
2545        assert!(windows[0].get("behind").is_none());
2546        // The main repo name is enriched onto the entry.
2547        assert_eq!(
2548            windows[0].get("main_repo").and_then(Value::as_str),
2549            dir.path().file_name().and_then(|n| n.to_str())
2550        );
2551
2552        // A non-repo folder is still listed, just without a branch or main repo.
2553        let plain = tempfile::tempdir().unwrap();
2554        svc.handle(
2555            "register",
2556            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
2557        )
2558        .await
2559        .unwrap();
2560        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
2561        let w2 = windows
2562            .iter()
2563            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
2564            .unwrap();
2565        assert!(w2.get("branch").is_none());
2566        assert!(w2.get("main_repo").is_none());
2567    }
2568
2569    #[test]
2570    fn window_label_prefers_git_branch_over_title() {
2571        let dir = tempfile::tempdir().unwrap();
2572        let repo = init_repo(dir.path());
2573        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2574        repo.set_head("refs/heads/main").unwrap();
2575        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
2576        let entry = WindowEntry {
2577            key: "k".to_string(),
2578            folders: vec![dir.path().to_path_buf()],
2579            // Both the companion `repo` and `title` are overridden by the
2580            // git-derived main repo name and computed branch.
2581            repo: Some("companion-repo".to_string()),
2582            title: Some("ignored title".to_string()),
2583            pid: None,
2584            last_seen: Utc::now(),
2585        };
2586        // Main checkout: `repo · branch`, and with no upstream there is no sync.
2587        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
2588    }
2589
2590    #[tokio::test]
2591    async fn list_includes_ahead_behind_for_tracking_branch() {
2592        let dir = tempfile::tempdir().unwrap();
2593        let _repo = diverging_repo(dir.path());
2594
2595        let svc = WorktreesService::new();
2596        svc.handle(
2597            "register",
2598            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2599        )
2600        .await
2601        .unwrap();
2602        let payload = svc.handle("list", Value::Null).await.unwrap();
2603        let windows = windows_of(&payload);
2604        // A tracking branch serializes branch plus both divergence counts.
2605        assert_eq!(
2606            windows[0].get("branch").and_then(Value::as_str),
2607            Some("main")
2608        );
2609        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
2610        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
2611    }
2612
2613    #[test]
2614    fn window_label_includes_sync_for_tracking_branch() {
2615        let dir = tempfile::tempdir().unwrap();
2616        let _repo = diverging_repo(dir.path());
2617        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
2618        let entry = WindowEntry {
2619            key: "k".to_string(),
2620            folders: vec![dir.path().to_path_buf()],
2621            repo: Some("companion-repo".to_string()),
2622            title: None,
2623            pid: None,
2624            last_seen: Utc::now(),
2625        };
2626        // A tracking branch appends the `(+ahead -behind)` sync indicator.
2627        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
2628    }
2629
2630    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
2631    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
2632    /// <wt_path>`.
2633    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
2634        let commit = repo.find_commit(base).unwrap();
2635        repo.branch(branch, &commit, false).unwrap();
2636        let reference = repo
2637            .find_reference(&format!("refs/heads/{branch}"))
2638            .unwrap();
2639        let mut opts = git2::WorktreeAddOptions::new();
2640        opts.reference(Some(&reference));
2641        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
2642    }
2643
2644    #[test]
2645    fn git_status_marks_linked_worktree_and_names_parent_repo() {
2646        let main_dir = tempfile::tempdir().unwrap();
2647        let repo = init_repo(main_dir.path());
2648        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2649        repo.set_head("refs/heads/main").unwrap();
2650
2651        // A linked worktree checked out on a new `feature` branch, in a
2652        // directory whose basename is deliberately *not* the repo name.
2653        let wt_parent = tempfile::tempdir().unwrap();
2654        let wt_path = wt_parent.path().join("feature-wt");
2655        add_worktree(&repo, a, &wt_path, "feature");
2656
2657        let status = git_status(&wt_path);
2658        assert!(status.is_worktree);
2659        assert_eq!(status.branch.as_deref(), Some("feature"));
2660        // The worktree names its *parent* repo, not its worktree-folder basename.
2661        assert_eq!(
2662            status.main_repo.as_deref(),
2663            main_dir.path().file_name().and_then(|n| n.to_str())
2664        );
2665
2666        // The main checkout resolves the same repo name and is not a worktree.
2667        let main_status = git_status(main_dir.path());
2668        assert!(!main_status.is_worktree);
2669        assert_eq!(main_status.main_repo, status.main_repo);
2670    }
2671
2672    #[test]
2673    fn window_label_marks_worktree_with_fork_glyph() {
2674        let main_dir = tempfile::tempdir().unwrap();
2675        let repo = init_repo(main_dir.path());
2676        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2677        repo.set_head("refs/heads/main").unwrap();
2678        let wt_parent = tempfile::tempdir().unwrap();
2679        let wt_path = wt_parent.path().join("feature-wt");
2680        add_worktree(&repo, a, &wt_path, "feature");
2681
2682        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
2683        let entry = WindowEntry {
2684            key: "k".to_string(),
2685            folders: vec![wt_path],
2686            repo: Some("feature-wt".to_string()),
2687            title: None,
2688            pid: None,
2689            last_seen: Utc::now(),
2690        };
2691        // A worktree line: parent repo, the fork glyph, then the branch (no
2692        // upstream here, so no sync suffix).
2693        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
2694    }
2695
2696    #[test]
2697    fn main_repo_name_derives_from_common_dir() {
2698        // Normal layout: the repo is the directory that contains `.git`.
2699        assert_eq!(
2700            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
2701            Some("omni-dev")
2702        );
2703        // A trailing slash on the common dir does not change the answer.
2704        assert_eq!(
2705            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
2706            Some("omni-dev")
2707        );
2708        // A bare repo: its own directory name, without the `.git` suffix.
2709        assert_eq!(
2710            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
2711            Some("omni-dev")
2712        );
2713        // A `.git` at the filesystem root has no parent name to use.
2714        assert_eq!(main_repo_name(Path::new("/.git")), None);
2715    }
2716
2717    // --- Repo/worktree tree (#1265) ----------------------------------------
2718
2719    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
2720    /// temporary payload).
2721    fn repos_of(payload: &Value) -> Vec<Value> {
2722        payload
2723            .get("repos")
2724            .and_then(Value::as_array)
2725            .expect("repos array")
2726            .clone()
2727    }
2728
2729    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
2730        Some(GithubIdentity {
2731            owner: owner.to_string(),
2732            name: name.to_string(),
2733        })
2734    }
2735
2736    #[test]
2737    fn github_identity_parses_supported_forms() {
2738        // https / http, with and without the `.git` suffix.
2739        assert_eq!(
2740            github_identity("https://github.com/rust-works/omni-dev.git"),
2741            github("rust-works", "omni-dev")
2742        );
2743        assert_eq!(
2744            github_identity("https://github.com/rust-works/omni-dev"),
2745            github("rust-works", "omni-dev")
2746        );
2747        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
2748        // SCP-like and ssh:// / git:// forms.
2749        assert_eq!(
2750            github_identity("git@github.com:rust-works/omni-dev.git"),
2751            github("rust-works", "omni-dev")
2752        );
2753        assert_eq!(
2754            github_identity("ssh://git@github.com/o/r.git"),
2755            github("o", "r")
2756        );
2757        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
2758        // A trailing slash and surrounding whitespace are tolerated.
2759        assert_eq!(
2760            github_identity("  https://github.com/o/r/  "),
2761            github("o", "r")
2762        );
2763    }
2764
2765    #[test]
2766    fn github_identity_rejects_non_github_and_malformed() {
2767        // Non-GitHub hosts.
2768        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
2769        assert_eq!(github_identity("git@example.com:o/r.git"), None);
2770        // Missing or extra path segments.
2771        assert_eq!(github_identity("https://github.com/onlyowner"), None);
2772        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
2773        assert_eq!(github_identity("https://github.com/"), None);
2774        // Not a URL at all.
2775        assert_eq!(github_identity("not a url"), None);
2776    }
2777
2778    #[test]
2779    fn remote_github_identity_reads_origin_then_falls_back() {
2780        let dir = tempfile::tempdir().unwrap();
2781        let repo = init_repo(dir.path());
2782        // No remotes → None.
2783        assert_eq!(remote_github_identity(&repo), None);
2784        // A non-GitHub origin is not a match.
2785        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
2786        assert_eq!(remote_github_identity(&repo), None);
2787        // A GitHub origin resolves to its identity.
2788        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
2789            .unwrap();
2790        assert_eq!(
2791            remote_github_identity(&repo),
2792            github("rust-works", "omni-dev")
2793        );
2794
2795        // Origin non-GitHub but another remote is GitHub: the fallback loop over
2796        // the remaining remotes finds it.
2797        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
2798            .unwrap();
2799        repo.remote("upstream", "https://github.com/other/proj.git")
2800            .unwrap();
2801        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
2802    }
2803
2804    #[tokio::test]
2805    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
2806        let svc = WorktreesService::new();
2807        // No windows → an empty repo set (not an error), toggle at its default.
2808        assert_eq!(
2809            svc.handle("tree", Value::Null).await.unwrap(),
2810            json!({ "repos": [], "show_closed": true })
2811        );
2812        // A plain non-repo folder is skipped rather than sinking the op.
2813        let plain = tempfile::tempdir().unwrap();
2814        svc.handle(
2815            "register",
2816            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
2817        )
2818        .await
2819        .unwrap();
2820        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
2821    }
2822
2823    #[tokio::test]
2824    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
2825        let main_dir = tempfile::tempdir().unwrap();
2826        let repo = init_repo(main_dir.path());
2827        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2828        repo.set_head("refs/heads/main").unwrap();
2829        // A GitHub origin so the repo carries an identity in the payload.
2830        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
2831            .unwrap();
2832
2833        // A linked worktree on a new `feature` branch, in a directory whose
2834        // basename is deliberately not the repo name.
2835        let wt_parent = tempfile::tempdir().unwrap();
2836        let wt_path = wt_parent.path().join("feature-wt");
2837        add_worktree(&repo, a, &wt_path, "feature");
2838
2839        let svc = WorktreesService::new();
2840        // A window open on the main checkout and one on the linked worktree —
2841        // two windows, but one repo (they must dedupe).
2842        svc.handle(
2843            "register",
2844            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
2845        )
2846        .await
2847        .unwrap();
2848        svc.handle(
2849            "register",
2850            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
2851        )
2852        .await
2853        .unwrap();
2854
2855        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2856        assert_eq!(
2857            repos.len(),
2858            1,
2859            "two worktrees of one repo dedupe: {repos:?}"
2860        );
2861        let repo0 = &repos[0];
2862        // Repo identity is the parent-repo name (not a worktree-folder basename).
2863        assert_eq!(
2864            repo0.get("main_repo").and_then(Value::as_str),
2865            main_dir.path().file_name().and_then(|n| n.to_str())
2866        );
2867        assert_eq!(
2868            repo0.pointer("/github/owner").and_then(Value::as_str),
2869            Some("rust-works")
2870        );
2871        assert_eq!(
2872            repo0.pointer("/github/name").and_then(Value::as_str),
2873            Some("omni-dev")
2874        );
2875        assert!(repo0.get("root").and_then(Value::as_str).is_some());
2876
2877        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
2878        assert_eq!(worktrees.len(), 2);
2879        // Main working tree first: is_main, open, with the main window's key.
2880        let main_wt = &worktrees[0];
2881        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
2882        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
2883        assert_eq!(
2884            main_wt.get("window_key").and_then(Value::as_str),
2885            Some("wm")
2886        );
2887        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
2888        // Linked worktree: not main, open via the feature window.
2889        let linked = &worktrees[1];
2890        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
2891        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
2892        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
2893        assert_eq!(
2894            linked.get("branch").and_then(Value::as_str),
2895            Some("feature")
2896        );
2897    }
2898
2899    #[tokio::test]
2900    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
2901        let main_dir = tempfile::tempdir().unwrap();
2902        let repo = init_repo(main_dir.path());
2903        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2904        repo.set_head("refs/heads/main").unwrap();
2905        // No remote at all → the repo carries no `github` identity.
2906        let wt_parent = tempfile::tempdir().unwrap();
2907        let wt_path = wt_parent.path().join("feature-wt");
2908        add_worktree(&repo, a, &wt_path, "feature");
2909
2910        let svc = WorktreesService::new();
2911        // Only the main checkout has a window; the linked worktree has none.
2912        svc.handle(
2913            "register",
2914            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
2915        )
2916        .await
2917        .unwrap();
2918
2919        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
2920        assert_eq!(repos.len(), 1);
2921        assert!(repos[0].get("github").is_none(), "no remote → no github");
2922        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
2923        let linked = worktrees
2924            .iter()
2925            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
2926            .expect("the linked worktree");
2927        // Enumerated even though no window has it open, and marked closed.
2928        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
2929        assert!(linked.get("window_key").is_none());
2930    }
2931
2932    // --- Close op (#1277) --------------------------------------------------
2933
2934    /// Builds a repo whose main working tree is on `trunk` with one **clean**
2935    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
2936    /// paths stay valid) and the linked worktree path.
2937    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
2938        let main_dir = tempfile::tempdir().unwrap();
2939        let repo = init_repo(main_dir.path());
2940        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
2941        repo.set_head("refs/heads/trunk").unwrap();
2942        let wt_parent = tempfile::tempdir().unwrap();
2943        let wt_path = wt_parent.path().join("feature-wt");
2944        add_worktree(&repo, a, &wt_path, "feature");
2945        (main_dir, wt_parent, wt_path)
2946    }
2947
2948    #[tokio::test]
2949    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
2950        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2951        let svc = WorktreesService::new();
2952        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
2953        // main, no risks → the extension proceeds with no dialog.
2954        let report = svc
2955            .handle("close", json!({ "path": wt_path, "remove": true }))
2956            .await
2957            .unwrap();
2958        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
2959        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
2960        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
2961        assert!(report
2962            .get("risks")
2963            .and_then(Value::as_array)
2964            .unwrap()
2965            .is_empty());
2966        // No side effects: the worktree still exists.
2967        assert!(wt_path.exists());
2968    }
2969
2970    #[tokio::test]
2971    async fn close_removes_a_clean_linked_worktree() {
2972        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2973        let svc = WorktreesService::new();
2974        let reply = svc
2975            .handle(
2976                "close",
2977                json!({ "path": wt_path, "remove": true, "confirmed": true }),
2978            )
2979            .await
2980            .unwrap();
2981        assert_eq!(reply, json!({ "removed": true }));
2982        assert!(
2983            !wt_path.exists(),
2984            "the worktree directory should be deleted"
2985        );
2986    }
2987
2988    #[tokio::test]
2989    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
2990        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
2991        // An untracked file in the worktree would be lost on removal.
2992        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
2993
2994        let svc = WorktreesService::new();
2995        let report = svc
2996            .handle("close", json!({ "path": wt_path, "remove": true }))
2997            .await
2998            .unwrap();
2999        let risks = report.get("risks").and_then(Value::as_array).unwrap();
3000        assert!(
3001            risks
3002                .iter()
3003                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
3004            "expected an untracked risk: {report}"
3005        );
3006        // Still removable — the risk only means "confirm first", not "refuse".
3007        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3008        // The unconfirmed check has no side effects.
3009        assert!(wt_path.exists());
3010    }
3011
3012    #[tokio::test]
3013    async fn close_confirmed_removes_a_dirty_worktree() {
3014        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3015        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
3016        let svc = WorktreesService::new();
3017        // With confirmation, the risks are overridden and removal proceeds.
3018        let reply = svc
3019            .handle(
3020                "close",
3021                json!({ "path": wt_path, "remove": true, "confirmed": true }),
3022            )
3023            .await
3024            .unwrap();
3025        assert_eq!(reply, json!({ "removed": true }));
3026        assert!(!wt_path.exists());
3027    }
3028
3029    #[tokio::test]
3030    async fn close_refuses_to_remove_the_main_working_tree() {
3031        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
3032        let svc = WorktreesService::new();
3033        // Phase 1: the main tree reports not-removable, marked main.
3034        let report = svc
3035            .handle("close", json!({ "path": main.path(), "remove": true }))
3036            .await
3037            .unwrap();
3038        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
3039        assert_eq!(
3040            report.get("removable").and_then(Value::as_bool),
3041            Some(false)
3042        );
3043        // Phase 2: even a confirmed delete of the main tree is refused
3044        // defensively, and the directory is untouched.
3045        assert!(svc
3046            .handle(
3047                "close",
3048                json!({ "path": main.path(), "remove": true, "confirmed": true }),
3049            )
3050            .await
3051            .is_err());
3052        assert!(main.path().exists());
3053    }
3054
3055    #[tokio::test]
3056    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
3057        // The case a naive impl would wrongly protect: a linked worktree checked
3058        // out on `main` (the default branch) is *still a linked worktree*, so it
3059        // is fully deletable — and `main` survives (removal never deletes a branch).
3060        let main_dir = tempfile::tempdir().unwrap();
3061        let repo = init_repo(main_dir.path());
3062        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
3063        repo.set_head("refs/heads/trunk").unwrap();
3064        let wt_parent = tempfile::tempdir().unwrap();
3065        let wt_path = wt_parent.path().join("main-wt");
3066        add_worktree(&repo, a, &wt_path, "main");
3067
3068        let svc = WorktreesService::new();
3069        let reply = svc
3070            .handle(
3071                "close",
3072                json!({ "path": wt_path, "remove": true, "confirmed": true }),
3073            )
3074            .await
3075            .unwrap();
3076        assert_eq!(reply, json!({ "removed": true }));
3077        assert!(!wt_path.exists());
3078        // The `main` branch is untouched by the worktree removal.
3079        assert!(
3080            repo.find_branch("main", git2::BranchType::Local).is_ok(),
3081            "the default branch must survive worktree removal"
3082        );
3083    }
3084
3085    #[tokio::test]
3086    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
3087        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3088        let svc = WorktreesService::new();
3089        // First removal succeeds.
3090        svc.handle(
3091            "close",
3092            json!({ "path": wt_path, "remove": true, "confirmed": true }),
3093        )
3094        .await
3095        .unwrap();
3096        // A second confirmed close of the now-missing path is a clean success,
3097        // not an error (a stale snapshot must not crash).
3098        let reply = svc
3099            .handle(
3100                "close",
3101                json!({ "path": wt_path, "remove": true, "confirmed": true }),
3102            )
3103            .await
3104            .unwrap();
3105        assert_eq!(reply, json!({ "removed": true }));
3106    }
3107
3108    #[tokio::test]
3109    async fn close_safety_check_detects_detached_head_unreachable_commits() {
3110        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3111        // In the worktree, commit onto a detached HEAD so the new commit is
3112        // reachable from no ref — it would be GC'd on removal.
3113        let wt_repo = Repository::open(&wt_path).unwrap();
3114        let parent_oid = wt_repo.head().unwrap().target().unwrap();
3115        let parent = wt_repo.find_commit(parent_oid).unwrap();
3116        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
3117        wt_repo.set_head_detached(orphan).unwrap();
3118
3119        let svc = WorktreesService::new();
3120        let report = svc
3121            .handle("close", json!({ "path": wt_path, "remove": true }))
3122            .await
3123            .unwrap();
3124        let risks = report.get("risks").and_then(Value::as_array).unwrap();
3125        assert!(
3126            risks
3127                .iter()
3128                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
3129            "expected an unreachable-commits risk: {report}"
3130        );
3131    }
3132
3133    #[tokio::test]
3134    async fn close_self_close_removes_when_the_requester_owns_the_target() {
3135        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3136        let svc = WorktreesService::new();
3137        // The requesting window itself has the worktree open: it is the only
3138        // owning window, so there is nothing to wait on — remove and reply, and
3139        // the extension closes its own window on `ok`.
3140        svc.handle(
3141            "register",
3142            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
3143        )
3144        .await
3145        .unwrap();
3146        let reply = svc
3147            .handle(
3148                "close",
3149                json!({
3150                    "path": wt_path,
3151                    "remove": true,
3152                    "confirmed": true,
3153                    "requester_key": "w1",
3154                }),
3155            )
3156            .await
3157            .unwrap();
3158        assert_eq!(reply, json!({ "removed": true }));
3159        assert!(!wt_path.exists());
3160    }
3161
3162    #[tokio::test]
3163    async fn close_safety_check_surfaces_the_owning_window() {
3164        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3165        let svc = WorktreesService::new();
3166        // A multi-root window owns the target: the report surfaces its key and
3167        // folder count so the extension can warn "all N folders will close".
3168        svc.handle(
3169            "register",
3170            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
3171        )
3172        .await
3173        .unwrap();
3174        let report = svc
3175            .handle("close", json!({ "path": wt_path, "remove": true }))
3176            .await
3177            .unwrap();
3178        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
3179        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
3180        assert_eq!(
3181            report.get("window_folder_count").and_then(Value::as_u64),
3182            Some(2)
3183        );
3184    }
3185
3186    #[tokio::test]
3187    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
3188        let svc = WorktreesService::new();
3189        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
3190            .await
3191            .unwrap();
3192        // No directive → a plain `{ known: true }`, byte-identical to before.
3193        assert_eq!(
3194            svc.handle("heartbeat", json!({ "key": "w1" }))
3195                .await
3196                .unwrap(),
3197            json!({ "known": true })
3198        );
3199        // Marked → the next heartbeat carries `close: true`, exactly once.
3200        svc.registry.mark_close_pending("w1");
3201        assert_eq!(
3202            svc.handle("heartbeat", json!({ "key": "w1" }))
3203                .await
3204                .unwrap(),
3205            json!({ "known": true, "close": true })
3206        );
3207        assert_eq!(
3208            svc.handle("heartbeat", json!({ "key": "w1" }))
3209                .await
3210                .unwrap(),
3211            json!({ "known": true })
3212        );
3213    }
3214
3215    #[tokio::test]
3216    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
3217        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3218        let svc = Arc::new(WorktreesService::new());
3219        // A *different* window (not the requester) owns the target.
3220        svc.handle(
3221            "register",
3222            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
3223        )
3224        .await
3225        .unwrap();
3226
3227        // Drive the destructive close concurrently: it marks w2 to close and
3228        // waits for it to unregister before removing.
3229        let svc2 = svc.clone();
3230        let path = wt_path.clone();
3231        let close = tokio::spawn(async move {
3232            svc2.handle(
3233                "close",
3234                json!({
3235                    "path": path,
3236                    "remove": true,
3237                    "confirmed": true,
3238                    "requester_key": "w1",
3239                }),
3240            )
3241            .await
3242        });
3243
3244        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
3245        // closes its window and unregisters. Poll until the directive appears.
3246        let mut saw_close = false;
3247        for _ in 0..200 {
3248            let hb = svc
3249                .handle("heartbeat", json!({ "key": "w2" }))
3250                .await
3251                .unwrap();
3252            if hb.get("close").and_then(Value::as_bool) == Some(true) {
3253                saw_close = true;
3254                svc.handle("unregister", json!({ "key": "w2" }))
3255                    .await
3256                    .unwrap();
3257                break;
3258            }
3259            tokio::time::sleep(Duration::from_millis(5)).await;
3260        }
3261        assert!(saw_close, "w2 should have been told to close");
3262
3263        // Once w2 has unregistered, the close op removes the worktree.
3264        let reply = close.await.unwrap().unwrap();
3265        assert_eq!(reply, json!({ "removed": true }));
3266        assert!(!wt_path.exists());
3267    }
3268
3269    #[tokio::test]
3270    async fn await_windows_closed_times_out_when_a_window_never_closes() {
3271        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3272        let svc = WorktreesService::new();
3273        svc.handle(
3274            "register",
3275            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
3276        )
3277        .await
3278        .unwrap();
3279        // The owning window never unregisters: the wait gives up (with a short
3280        // timeout here) rather than block, and names the still-open window.
3281        let err = await_windows_closed(
3282            &svc.registry,
3283            &wt_path,
3284            Some("w1"),
3285            Duration::from_millis(150),
3286            Duration::from_millis(25),
3287        )
3288        .await
3289        .unwrap_err();
3290        assert!(
3291            err.to_string().contains("w2"),
3292            "error names the window: {err}"
3293        );
3294        // The requester itself is excluded, so a self-only owner returns at once.
3295        await_windows_closed(
3296            &svc.registry,
3297            &wt_path,
3298            Some("w2"),
3299            Duration::from_millis(150),
3300            Duration::from_millis(25),
3301        )
3302        .await
3303        .unwrap();
3304    }
3305
3306    #[tokio::test]
3307    async fn close_window_without_remove_replies_closed_and_never_deletes() {
3308        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
3309        let svc = WorktreesService::new();
3310        // "Close Window" on the main tree: no git inspection, no removal.
3311        let reply = svc
3312            .handle("close", json!({ "path": main.path(), "remove": false }))
3313            .await
3314            .unwrap();
3315        assert_eq!(reply, json!({ "closed": true }));
3316        assert!(main.path().exists());
3317    }
3318
3319    #[tokio::test]
3320    async fn close_safety_check_flags_modified_tracked_files() {
3321        // A tracked file, checked out into the linked worktree, then modified —
3322        // its content is lost on removal, so it is a `dirty` risk (distinct from
3323        // the untracked case).
3324        let main_dir = tempfile::tempdir().unwrap();
3325        let repo = init_repo(main_dir.path());
3326        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
3327        repo.set_head("refs/heads/trunk").unwrap();
3328        let wt_parent = tempfile::tempdir().unwrap();
3329        let wt_path = wt_parent.path().join("feature-wt");
3330        add_worktree(&repo, a, &wt_path, "feature");
3331        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
3332
3333        let svc = WorktreesService::new();
3334        let report = svc
3335            .handle("close", json!({ "path": wt_path, "remove": true }))
3336            .await
3337            .unwrap();
3338        let risks = report.get("risks").and_then(Value::as_array).unwrap();
3339        assert!(
3340            risks
3341                .iter()
3342                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
3343            "expected a dirty risk: {report}"
3344        );
3345    }
3346
3347    #[tokio::test]
3348    async fn close_safety_check_flags_an_in_progress_operation() {
3349        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
3350        // non-Clean (interrupted merge) state — its progress is lost on removal.
3351        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3352        let wt_repo = Repository::open(&wt_path).unwrap();
3353        let head = wt_repo.head().unwrap().target().unwrap();
3354        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
3355        assert_ne!(wt_repo.state(), RepositoryState::Clean);
3356
3357        let svc = WorktreesService::new();
3358        let report = svc
3359            .handle("close", json!({ "path": wt_path, "remove": true }))
3360            .await
3361            .unwrap();
3362        let risks = report.get("risks").and_then(Value::as_array).unwrap();
3363        assert!(
3364            risks
3365                .iter()
3366                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
3367            "expected an in-progress risk: {report}"
3368        );
3369    }
3370
3371    #[tokio::test]
3372    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
3373        // A linked worktree on `feature`, which tracks `origin/feature` and is one
3374        // commit ahead. The unpushed commit is INFO (the branch — and thus the
3375        // commit — survives removal), never a blocking risk.
3376        let main_dir = tempfile::tempdir().unwrap();
3377        let repo = init_repo(main_dir.path());
3378        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
3379        repo.set_head("refs/heads/trunk").unwrap();
3380        let a_commit = repo.find_commit(a).unwrap();
3381        repo.branch("feature", &a_commit, false).unwrap();
3382        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
3383            .unwrap();
3384        // `feature` advances one commit past `origin/feature`.
3385        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
3386        drop(a_commit);
3387        let mut cfg = repo.config().unwrap();
3388        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
3389            .unwrap();
3390        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
3391            .unwrap();
3392        cfg.set_str("branch.feature.remote", "origin").unwrap();
3393        cfg.set_str("branch.feature.merge", "refs/heads/feature")
3394            .unwrap();
3395        // A worktree on the existing `feature` branch (not created fresh, so it
3396        // keeps the ahead-of-upstream divergence).
3397        let wt_parent = tempfile::tempdir().unwrap();
3398        let wt_path = wt_parent.path().join("feature-wt");
3399        let reference = repo.find_reference("refs/heads/feature").unwrap();
3400        let mut opts = git2::WorktreeAddOptions::new();
3401        opts.reference(Some(&reference));
3402        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
3403
3404        let svc = WorktreesService::new();
3405        let report = svc
3406            .handle("close", json!({ "path": wt_path, "remove": true }))
3407            .await
3408            .unwrap();
3409        // Unpushed commits appear as `info`, and the worktree is still cleanly
3410        // removable with no blocking risks.
3411        let info = report.get("info").and_then(Value::as_array).unwrap();
3412        assert!(
3413            info.iter()
3414                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
3415            "expected an unpushed info note: {report}"
3416        );
3417        assert!(
3418            report
3419                .get("risks")
3420                .and_then(Value::as_array)
3421                .unwrap()
3422                .is_empty(),
3423            "unpushed commits alone must not block: {report}"
3424        );
3425        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3426    }
3427
3428    #[tokio::test]
3429    async fn close_safety_check_ignores_gitignored_files() {
3430        // With `.gitignore` committed, an ignored artifact is the only worktree
3431        // change — it must not count as untracked (it is regenerable), so the
3432        // worktree stays cleanly removable with no risks.
3433        let main_dir = tempfile::tempdir().unwrap();
3434        let repo = init_repo(main_dir.path());
3435        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
3436        repo.set_head("refs/heads/trunk").unwrap();
3437        let wt_parent = tempfile::tempdir().unwrap();
3438        let wt_path = wt_parent.path().join("feature-wt");
3439        add_worktree(&repo, a, &wt_path, "feature");
3440        std::fs::create_dir(wt_path.join("build")).unwrap();
3441        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
3442
3443        let svc = WorktreesService::new();
3444        let report = svc
3445            .handle("close", json!({ "path": wt_path, "remove": true }))
3446            .await
3447            .unwrap();
3448        assert!(
3449            report
3450                .get("risks")
3451                .and_then(Value::as_array)
3452                .unwrap()
3453                .is_empty(),
3454            "a gitignored file must not create a risk: {report}"
3455        );
3456        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3457    }
3458
3459    #[tokio::test]
3460    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
3461        // The phase-1 check on a path that no longer exists reports it removable
3462        // with no risks (so the idempotent execute proceeds with no dialog).
3463        let svc = WorktreesService::new();
3464        let report = svc
3465            .handle(
3466                "close",
3467                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
3468            )
3469            .await
3470            .unwrap();
3471        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
3472        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
3473        assert!(report
3474            .get("risks")
3475            .and_then(Value::as_array)
3476            .unwrap()
3477            .is_empty());
3478        let info = report.get("info").and_then(Value::as_array).unwrap();
3479        assert!(info
3480            .iter()
3481            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
3482    }
3483
3484    #[tokio::test]
3485    async fn close_refuses_a_locked_worktree() {
3486        // A locked worktree (git worktree lock) must be refused, not forced past
3487        // (failure mode #6), and left on disk.
3488        let (main, _wtp, wt_path) = repo_with_linked_worktree();
3489        let main_repo = Repository::open(main.path()).unwrap();
3490        main_repo
3491            .find_worktree("feature")
3492            .unwrap()
3493            .lock(Some("under test"))
3494            .unwrap();
3495
3496        let svc = WorktreesService::new();
3497        let err = svc
3498            .handle(
3499                "close",
3500                json!({ "path": wt_path, "remove": true, "confirmed": true }),
3501            )
3502            .await
3503            .unwrap_err();
3504        assert!(
3505            err.to_string().contains("locked"),
3506            "expected a locked error: {err}"
3507        );
3508        assert!(wt_path.exists(), "a locked worktree must not be removed");
3509    }
3510
3511    #[tokio::test]
3512    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
3513        // A detached HEAD that still sits on a commit a branch points to loses
3514        // nothing on removal, so it must NOT produce an unreachable-commits risk
3515        // (the false-positive the reachability walk guards against).
3516        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3517        let wt_repo = Repository::open(&wt_path).unwrap();
3518        // The worktree is on `feature`; detach HEAD onto its current tip, which
3519        // the `feature` branch still references.
3520        let tip = wt_repo.head().unwrap().target().unwrap();
3521        wt_repo.set_head_detached(tip).unwrap();
3522        assert!(wt_repo.head_detached().unwrap());
3523
3524        let svc = WorktreesService::new();
3525        let report = svc
3526            .handle("close", json!({ "path": wt_path, "remove": true }))
3527            .await
3528            .unwrap();
3529        let risks = report.get("risks").and_then(Value::as_array).unwrap();
3530        assert!(
3531            !risks
3532                .iter()
3533                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
3534            "a detached HEAD reachable from a branch must not be flagged: {report}"
3535        );
3536    }
3537
3538    #[test]
3539    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
3540        let (main, _wtp, wt_path) = repo_with_linked_worktree();
3541        let main_repo = Repository::open(main.path()).unwrap();
3542        // The real linked worktree resolves to its registered name.
3543        assert_eq!(
3544            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
3545            "feature"
3546        );
3547        // A path that is not one of this repo's worktrees is the defensive
3548        // "not registered" error (the guard behind removal).
3549        let err =
3550            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
3551        assert!(
3552            err.to_string().contains("not registered"),
3553            "expected a not-registered error: {err}"
3554        );
3555    }
3556
3557    #[test]
3558    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
3559        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
3560        // rather than sinking the whole safety check.
3561        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
3562        let repo = Repository::open(&wt_path).unwrap();
3563        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
3564        // Confirm the corruption actually breaks status enumeration, so the
3565        // count is exercising the error-degradation branch (not an empty repo).
3566        assert!(
3567            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
3568            "a corrupt index should make statuses() fail"
3569        );
3570        assert_eq!(count_dirty_untracked(&repo), (0, 0));
3571    }
3572}