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