Skip to main content

omni_dev/daemon/services/
worktrees.rs

1//! The worktrees daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`WorktreesRegistry`] under the
4//! daemon's lifecycle and exposes register/heartbeat/unregister/list/tree/open
5//! over the control socket, plus a tray submenu with a per-window "focus" action.
6//! The `open` op (#1266) focuses/opens an arbitrary worktree folder in VS Code
7//! through the **same** launcher path the tray uses, so a socket client (the
8//! companion's double-click) shares the tested guard and launcher resolution
9//! rather than duplicating them.
10//!
11//! All registry state and liveness logic (the `Mutex<HashMap>`, TTL reaping, the
12//! entry cap/eviction) lives in [`crate::worktrees`]; this adapter only routes
13//! ops, renders the menu/status, and drives the VS Code launcher. Like the
14//! Snowflake service it is a cheap, in-memory adapter — no async setup, no
15//! secret persisted.
16//!
17//! The adapter also computes the **per-worktree git enrichment** (current
18//! branch, ahead/behind counts, and the parent repository a linked worktree
19//! belongs to) on read via `git2` (#1186), keeping the companion a thin reporter
20//! of raw folder paths (ADR-0040). The engine stores only what the companion
21//! sends; disk I/O for the enrichment lives here, alongside the launcher, never
22//! under the registry lock.
23//!
24//! The `tree` op (#1265) inverts the data model for the companion's tree view:
25//! from the open windows the adapter derives the **distinct repositories**, then
26//! enumerates **all** of each repo's worktrees (main working tree +
27//! [`Repository::worktrees`]), enriches each (reusing [`git_status`]), tags the
28//! GitHub identity of `origin`, and joins the open windows back on by
29//! canonicalized path. The open-window registry stays the liveness source;
30//! "is a window open on it?" becomes a per-worktree attribute. All of this is
31//! git disk I/O, so it runs on a blocking thread, never under the registry lock.
32
33use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, PoisonError};
38use std::time::{Duration, Instant};
39
40use anyhow::{anyhow, bail, Context, Result};
41
42use crate::pr_status::{PrBadge, PrCheckState, PrStatusCache, PrTarget};
43use async_trait::async_trait;
44use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
45use serde::{Deserialize, Serialize};
46use serde_json::{json, Value};
47use tokio::sync::watch;
48use tokio::sync::Mutex as AsyncMutex;
49use tokio::task::JoinHandle;
50use tokio_util::sync::CancellationToken;
51
52use crate::daemon::service::{
53    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
54};
55use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
56
57/// The worktrees service name (the control-socket routing key).
58pub const SERVICE_NAME: &str = "worktrees";
59
60/// The tray submenu title.
61const SUBMENU_TITLE: &str = "Worktrees";
62
63/// Environment override for the VS Code launcher used by the "focus" tray
64/// action, for when the daemon runs under launchd with a minimal `PATH`.
65const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
66
67/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
68/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
69const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
70
71/// Default cadence at which the background task recomputes the tray menu snapshot
72/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
73/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
74/// the GUI thread (which would peg a core and stall shutdown — the #1186
75/// regression); the interval only governs how stale that cached branch/sync state
76/// may be when the menu is opened.
77///
78/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
79/// walk that the subscription-stream coalescing (#1303) never touched — it was
80/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
81/// menu open-latency unchanged (the cache still serves instantly).
82const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
83
84/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
85/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
86fn menu_refresh_interval() -> Duration {
87    crate::daemon::server::duration_secs_from_env(
88        ENV_MENU_REFRESH_INTERVAL,
89        DEFAULT_MENU_REFRESH_INTERVAL,
90    )
91}
92
93/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
94/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
95/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
96const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
97
98/// Default cadence for the PR badge poller while CI is in flight (#1337).
99///
100/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
101/// watching a run — which is exactly this situation. It is affordable because the
102/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
103/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
104/// while something is actually pending.
105const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
106
107/// The ceiling the poller backs off to once every badge is terminal.
108///
109/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
110/// The 30-minute figure is the cross-tool consensus for background PR polling
111/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
112/// defaults). The backoff exists for battery and wakeups rather than budget — at
113/// 1 point per poll the budget never binds.
114const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
115
116/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
117/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
118fn pr_poll_interval() -> Duration {
119    crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
120}
121
122/// A running background menu-refresh task and the token that stops it.
123struct RefreshTask {
124    /// Cancelled by `shutdown` to end the refresh loop.
125    token: CancellationToken,
126    /// The spawned loop, awaited on shutdown so it fully unwinds.
127    handle: JoinHandle<()>,
128}
129
130/// Whether this tick should spend a `gh` call.
131///
132/// The poller wakes far more often than it fetches — waking is a cached snapshot
133/// read, fetching is a subprocess and a network round trip. Two things justify the
134/// call: the watched state **moved** (a window opened, or a commit landed — the
135/// latter being invisible to the change-notify, so only looking finds it), or the
136/// **backoff elapsed** and it is simply time to look again.
137///
138/// Pure so the policy is testable directly: from outside, the only evidence of it
139/// is *when* a subprocess runs, which a test cannot pin down without either flaking
140/// or passing for the wrong reason.
141fn pr_should_fetch(moved: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
142    // `map_or(true, ..)` rather than `is_none_or`: the latter is stable only
143    // since 1.82 and this crate's MSRV is 1.80.
144    moved || since_last_fetch.map_or(true, |elapsed| elapsed >= backoff)
145}
146
147/// The next PR-poll delay: `base` while something is still pending, else double
148/// the current delay up to [`MAX_PR_POLL_INTERVAL`].
149///
150/// A pure function rather than three copies inline, because the cadence is the part
151/// worth pinning: it is only observable from outside as timing, which is exactly
152/// what a test cannot assert without flaking. A failed poll passes `pending: false`,
153/// so a persistent failure backs off instead of being retried hard.
154fn next_pr_poll_delay(current: Duration, base: Duration, pending: bool) -> Duration {
155    if pending {
156        base
157    } else {
158        current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL)
159    }
160}
161
162/// A running background PR-badge poll task and the token that stops it.
163struct PollerTask {
164    /// Cancelled by `shutdown` to end the poll loop.
165    token: CancellationToken,
166    /// The spawned loop, awaited on shutdown so it fully unwinds.
167    handle: JoinHandle<()>,
168}
169
170/// One thing the PR poller watches: a badge target, the commit the worktree
171/// carrying it currently has checked out, and the commit its upstream points at.
172///
173/// The two OIDs are what make local work observable to the poller. A window
174/// opening bumps the registry's change-notify, but nothing notifies the daemon
175/// when you commit or push — so the poller compares this against the previous
176/// tick's and treats any move as "go and ask now". Both are needed because they
177/// move on different actions: committing moves the head, while pushing moves
178/// **only** the upstream (#1344). Without the upstream, a push — the very thing
179/// that starts the CI run a badge reports — did not re-ask, and the badge sat at
180/// `●` until the backoff elapsed, up to its 30-minute ceiling.
181#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
182struct PrWatch {
183    /// The (repo, branch) to resolve a badge for.
184    target: PrTarget,
185    /// That worktree's HEAD, or `None` for an unborn one.
186    head_sha: Option<String>,
187    /// That branch's upstream tip, or `None` when it tracks no upstream.
188    upstream_sha: Option<String>,
189}
190
191/// Extracts what the poller watches — the badge targets and their local heads and
192/// upstream tips — from a `tree` snapshot.
193///
194/// Reading them back off the snapshot — rather than walking git again — means the
195/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
196/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
197/// out of its way to remove. Only GitHub repos with a branch contribute; the result
198/// is sorted and deduped so N worktrees of one repo on one branch ask once.
199fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
200    let mut out = Vec::new();
201    for repo in snapshot
202        .get("repos")
203        .and_then(Value::as_array)
204        .into_iter()
205        .flatten()
206    {
207        let Some(github) = repo.get("github") else {
208            continue;
209        };
210        let (Some(owner), Some(name)) = (
211            github.get("owner").and_then(Value::as_str),
212            github.get("name").and_then(Value::as_str),
213        ) else {
214            continue;
215        };
216        for wt in repo
217            .get("worktrees")
218            .and_then(Value::as_array)
219            .into_iter()
220            .flatten()
221        {
222            if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
223                out.push(PrWatch {
224                    head_sha: wt
225                        .get("head_sha")
226                        .and_then(Value::as_str)
227                        .map(str::to_string),
228                    upstream_sha: wt
229                        .get("upstream_sha")
230                        .and_then(Value::as_str)
231                        .map(str::to_string),
232                    target: PrTarget {
233                        owner: owner.to_string(),
234                        name: name.to_string(),
235                        branch: branch.to_string(),
236                    },
237                });
238            }
239        }
240    }
241    out.sort();
242    out.dedup();
243    out
244}
245
246/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
247/// without the heads.
248#[cfg(test)]
249fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
250    pr_watch_from_snapshot(snapshot)
251        .into_iter()
252        .map(|w| w.target)
253        .collect()
254}
255
256/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
257pub struct WorktreesService {
258    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
259    /// the background menu-refresh task can read it off the main thread.
260    registry: Arc<WorktreesRegistry>,
261    /// The most recent tray menu snapshot, recomputed off the main thread by
262    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
263    /// of this so it never blocks on git enrichment. `None` until the first
264    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
265    /// which case `menu()` falls back to a one-off inline compute.
266    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
267    /// The background refresh task, once started (`None` in tests / no runtime).
268    refresh: Mutex<Option<RefreshTask>>,
269    /// PR badges resolved by the background poller and read by the tree snapshot
270    /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
271    /// share the one cache. Empty until the first poll lands — and always empty
272    /// when no poller runs (unit tests), in which case the tree simply carries no
273    /// `pr` field, exactly as a pre-#1337 daemon did.
274    pr_cache: Arc<PrStatusCache>,
275    /// The background PR-badge poll task, once started (`None` in tests / no
276    /// runtime).
277    poller: Mutex<Option<PollerTask>>,
278    /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
279    /// through, so N open windows perform **one** `build_tree` per tick instead
280    /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
281    /// one cache. The one-shot `tree` op deliberately bypasses it and computes
282    /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
283    tree_cache: Arc<TreeSnapshotCache>,
284}
285
286impl WorktreesService {
287    /// Creates the service with an empty registry. Cheap — no I/O and no task;
288    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
289    /// off-thread menu caching, while tests use the bare service (menu computed
290    /// inline on demand).
291    #[must_use]
292    pub fn new() -> Self {
293        let registry = Arc::new(WorktreesRegistry::new());
294        let pr_cache = Arc::new(PrStatusCache::new());
295        Self {
296            registry: registry.clone(),
297            menu_cache: Arc::new(Mutex::new(None)),
298            refresh: Mutex::new(None),
299            pr_cache: pr_cache.clone(),
300            poller: Mutex::new(None),
301            tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
302        }
303    }
304
305    /// Starts the background task that recomputes the tray menu snapshot every
306    /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
307    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
308    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
309    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
310    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
311    /// keep computing the menu inline.
312    pub fn start_menu_refresh(&self) {
313        if tokio::runtime::Handle::try_current().is_err() {
314            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
315            return;
316        }
317        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
318        if guard.is_some() {
319            return;
320        }
321        let token = CancellationToken::new();
322        let loop_token = token.clone();
323        let registry = self.registry.clone();
324        let cache = self.menu_cache.clone();
325        // Resolved once at spawn: the interval is process-stable env config, and
326        // re-reading it every loop would be wasted work.
327        let interval = menu_refresh_interval();
328        let handle = tokio::spawn(async move {
329            loop {
330                // Snapshot the registry (a cheap lock), then build the menu —
331                // which opens repos and parses git config — on a blocking thread,
332                // never on this async worker or the tray's main thread.
333                let entries = registry.list();
334                if let Ok(items) =
335                    tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
336                {
337                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
338                }
339                tokio::select! {
340                    () = loop_token.cancelled() => break,
341                    () = tokio::time::sleep(interval) => {}
342                }
343            }
344        });
345        *guard = Some(RefreshTask { token, handle });
346    }
347
348    /// Starts the background task that keeps PR check badges fresh (#1337).
349    ///
350    /// This is the half of the badge nothing else can do. Badges used to be
351    /// resolved extension-side on repo-expand, so they were recomputed only when a
352    /// repo node's children were rebuilt — and the streamed snapshot carries
353    /// worktree topology, not CI. While CI ran and no window opened or closed,
354    /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
355    ///
356    /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
357    /// (cost 1, independent of repo/worktree/window count), writes the cache the
358    /// tree snapshot reads, and bumps the registry's change-notify **only when a
359    /// verdict actually moved** — so the server's diff pushes to every open window
360    /// exactly when CI state changes, and never otherwise.
361    ///
362    /// Cadence adapts: [`pr_poll_interval`] (~10 s) while any badge is pending,
363    /// doubling to [`MAX_PR_POLL_INTERVAL`] once everything is terminal, and it
364    /// polls nothing at all while no window is registered. That is a battery and
365    /// wakeup concern rather than a budget one.
366    ///
367    /// Idempotent, and a no-op outside a tokio runtime (mirroring
368    /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
369    /// heartbeat), so unit tests build a bare service that never spawns `gh`.
370    pub fn start_pr_poller(&self) {
371        // Both resolved once at spawn: process-stable env config (the
372        // menu-refresh precedent), never re-read per poll.
373        self.start_pr_poller_with(pr_poll_interval(), crate::pr_status::resolve_gh_binary());
374    }
375
376    /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence and
377    /// `gh` binary, so tests drive the loop at millisecond speed against a stub
378    /// **without mutating the process environment** — one global env var cannot
379    /// serve two parallel tests pointing at different fakes. Mirrors the
380    /// [`TreeSnapshotCache::with_ttl`] seam and the Snowflake heartbeat's
381    /// "interval via config, not env" rule.
382    fn start_pr_poller_with(&self, base: Duration, gh_bin: PathBuf) {
383        if tokio::runtime::Handle::try_current().is_err() {
384            tracing::debug!("no tokio runtime; worktrees PR poller not started");
385            return;
386        }
387        let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
388        if guard.is_some() {
389            return;
390        }
391        let token = CancellationToken::new();
392        let loop_token = token.clone();
393        let registry = self.registry.clone();
394        let tree_cache = self.tree_cache.clone();
395        let pr_cache = self.pr_cache.clone();
396        // Captured here, before the task's first sleep, so a window that registers
397        // while the loop is starting still wakes it rather than being missed.
398        let mut changes = self.registry.subscribe_changes();
399        let handle = tokio::spawn(async move {
400            // Two independent cadences. The loop *wakes* every `base` — cheap: a read
401            // of the coalescing snapshot cache, no subprocess, no network. It only
402            // *asks GitHub* when there is reason to: the watched state moved, or the
403            // backoff has elapsed.
404            //
405            // They have to be separate because the two things that should trigger a
406            // fetch arrive by different routes. A window opening bumps the registry's
407            // change-notify, but **a commit does not** — nothing in the daemon is
408            // notified when you `git push`. The only way to notice is to look, so the
409            // loop looks often and cheaply, and pays only when something changed.
410            let mut backoff = base;
411            let mut last_poll: Option<Instant> = None;
412            let mut watched: Option<Vec<PrWatch>> = None;
413            loop {
414                // Wait first: at startup no window has registered yet, and the
415                // first snapshot would be empty anyway.
416                tokio::select! {
417                    () = loop_token.cancelled() => break,
418                    () = tokio::time::sleep(base) => {}
419                    // A window opened or closed — look now rather than at the next
420                    // tick.
421                    result = changes.changed() => {
422                        // Unreachable today: this task owns an `Arc` of the registry
423                        // that holds the sender, so it cannot be dropped while we are
424                        // here. Kept anyway because the alternative is worse — a
425                        // closed channel makes `changed()` return `Ready` forever, so
426                        // ignoring the error would spin this loop at full speed,
427                        // re-snapshotting and re-running `gh` every iteration. One
428                        // unreachable line is a cheap guard against that.
429                        if result.is_err() {
430                            break;
431                        }
432                    }
433                }
434                // Off the coalescing snapshot cache, so this reuses the tick's
435                // `build_tree` rather than walking git a second time.
436                let snapshot = tree_cache.snapshot().await;
437                let watch = pr_watch_from_snapshot(&snapshot);
438                if watch.is_empty() {
439                    // No windows, or nothing on GitHub: ask nothing, and forget any
440                    // backoff so the next tree starts fresh rather than inheriting a
441                    // ceiling earned on a tree that no longer exists.
442                    backoff = base;
443                    last_poll = None;
444                    watched = None;
445                    continue;
446                }
447                // Did anything we care about move? A new commit shows up here as a
448                // changed head — which is exactly the push case the change-notify
449                // cannot see.
450                let moved = watched.as_ref() != Some(&watch);
451                if !pr_should_fetch(moved, last_poll.map(|at| at.elapsed()), backoff) {
452                    continue;
453                }
454                if moved {
455                    // Fresh work: watch it closely rather than serving out a backoff
456                    // earned while it was quiet.
457                    backoff = base;
458                }
459                let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
460                // `gh` is a blocking subprocess: never on an async worker. A join
461                // failure (the task panicked, or the runtime is going down) folds
462                // into the same error channel as a `gh` failure — both mean "no
463                // badges this round", and neither deserves its own handling.
464                let bin = gh_bin.clone();
465                let resolved = tokio::task::spawn_blocking(move || {
466                    crate::pr_status::resolve_with(&bin, &targets)
467                })
468                .await
469                .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
470                // Best-effort decoration: a missing/unauthenticated `gh`, a network
471                // blip, or a rate limit must never sink the tree. A failed poll
472                // leaves the last good badges in place rather than blanking every
473                // row, and is not "pending", so it backs off rather than hammers.
474                let pending = match resolved {
475                    Ok(badges) => {
476                        // Bump only on a real change, or the server's diff-and-drop
477                        // is defeated and every window re-renders on every poll.
478                        if pr_cache.replace(badges) {
479                            registry.bump();
480                        }
481                        pr_cache.any_pending()
482                    }
483                    Err(err) => {
484                        tracing::debug!("PR badge poll failed: {err:#}");
485                        false
486                    }
487                };
488                last_poll = Some(Instant::now());
489                // Record what this verdict was about, so the *next* tick can tell a
490                // genuine change from a quiet tree.
491                watched = Some(watch);
492                backoff = next_pr_poll_delay(backoff, base, pending);
493            }
494        });
495        *guard = Some(PollerTask { token, handle });
496    }
497
498    /// Handles the `close` op: close a worktree's window and, for a **linked**
499    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
500    ///
501    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
502    ///   [`git_safety`] check returning the risks of deleting, so the extension can
503    ///   show a modal confirm only when something would actually be lost.
504    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
505    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
506    ///   worktree. The main working tree is refused defensively.
507    ///
508    /// Cross-window signalling (another window has the target open) is a
509    /// fast-follow: this core handles the **no-window** and **self-close**
510    /// (`requester_key == target_key`) cases, and errors clearly when another
511    /// window owns the target so the destructive path is never taken blind.
512    async fn close(&self, req: CloseRequest) -> Result<Value> {
513        // Which live windows currently have the target open. The canonical-path
514        // compare is disk I/O, so run it (with the safety check below) on a
515        // blocking thread, never under the registry lock or on the async worker.
516        let entries = self.registry.list();
517        let scan_path = req.path.clone();
518        let open_windows =
519            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
520                .await
521                .unwrap_or_default();
522        let open = !open_windows.is_empty();
523        let window_key = open_windows.first().map(|(k, _)| k.clone());
524        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
525
526        // Phase 1: the safety check runs only for a delete request awaiting
527        // confirmation. A "Close Window" (remove:false) never inspects git and
528        // has nothing to confirm, so it skips straight to execute.
529        if req.remove && !req.confirmed {
530            let path = req.path.clone();
531            let git = tokio::task::spawn_blocking(move || git_safety(&path))
532                .await
533                .map_err(|e| anyhow!("safety check task panicked: {e}"))??;
534            return Ok(serde_json::to_value(SafetyReport {
535                removable: git.removable,
536                is_main: git.is_main,
537                open,
538                window_key,
539                window_folder_count,
540                risks: git.risks,
541                info: git.info,
542            })
543            .unwrap_or_else(|_| json!({})));
544        }
545
546        // Phase 2: execute. Signal every owning window *other than the
547        // requester* (which closes itself on our `ok:true` reply, avoiding the
548        // ext-host-dies-mid-op race) and wait for each to unregister before
549        // touching the worktree. The directive reaches a cross-window target via
550        // its heartbeat reply — the only channel the daemon has to a window it
551        // can reply to but never call.
552        let others: Vec<String> = open_windows
553            .iter()
554            .map(|(k, _)| k.clone())
555            .filter(|k| req.requester_key.as_deref() != Some(k))
556            .collect();
557        for key in &others {
558            self.registry.mark_close_pending(key);
559        }
560        if !others.is_empty() {
561            await_windows_closed(
562                &self.registry,
563                &req.path,
564                req.requester_key.as_deref(),
565                CLOSE_WAIT_TIMEOUT,
566                CLOSE_WAIT_POLL,
567            )
568            .await?;
569        }
570
571        if req.remove {
572            let path = req.path.clone();
573            tokio::task::spawn_blocking(move || remove_worktree(&path))
574                .await
575                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
576            Ok(json!({ "removed": true }))
577        } else {
578            // "Close Window" with no owning window is a no-op success; a
579            // self-close replies and the extension closes its own window.
580            Ok(json!({ "closed": true }))
581        }
582    }
583}
584
585impl Default for WorktreesService {
586    fn default() -> Self {
587        Self::new()
588    }
589}
590
591#[async_trait]
592impl DaemonService for WorktreesService {
593    fn name(&self) -> &'static str {
594        SERVICE_NAME
595    }
596
597    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
598        match op {
599            "register" => {
600                let req: RegisterRequest =
601                    serde_json::from_value(payload).context("invalid `register` payload")?;
602                if req.key.trim().is_empty() {
603                    bail!("`register` requires a non-empty `key`");
604                }
605                self.registry.register(req);
606                Ok(json!({ "ok": true }))
607            }
608            "heartbeat" => {
609                let key = require_str(&payload, "key", "heartbeat")?;
610                let known = self.registry.heartbeat(key);
611                // A pending close directive (#1277) rides the reply as an
612                // additive `close` field, taken-and-cleared here so it fires
613                // exactly once. Omitted when false to keep older windows — which
614                // read only `known` — byte-identical on the wire.
615                let mut reply = json!({ "known": known });
616                if self.registry.take_close_pending(key) {
617                    reply["close"] = Value::Bool(true);
618                }
619                Ok(reply)
620            }
621            "unregister" => {
622                let key = require_str(&payload, "key", "unregister")?;
623                Ok(json!({ "removed": self.registry.unregister(key) }))
624            }
625            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
626            "tree" => {
627                // The same `{ repos, show_closed }` snapshot the `subscribe`
628                // stream pushes, so a one-shot `tree` fetch and the live stream
629                // agree byte-for-byte (the git enumeration runs off-lock on a
630                // blocking thread inside the helper). Computed fresh here — the
631                // `tree` op is a rare manual refresh, deliberately bypassing the
632                // stream's coalescing cache so it never returns a stale view
633                // (#1303).
634                Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
635            }
636            "ahead-behind" => {
637                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
638                // snapshot no longer carries ahead/behind — the dominant
639                // per-worktree cost when computed eagerly every tick — so a client
640                // (the extension on expand, `worktrees tree`) asks for it here only
641                // for the worktrees it is about to show. Batched by path, one op per
642                // repo expand; the git walks run on a blocking thread.
643                let paths = payload
644                    .get("paths")
645                    .and_then(Value::as_array)
646                    .map(|arr| {
647                        arr.iter()
648                            .filter_map(Value::as_str)
649                            .map(PathBuf::from)
650                            .collect::<Vec<_>>()
651                    })
652                    .unwrap_or_default();
653                Ok(json!({ "results": ahead_behind_results(paths).await }))
654            }
655            "set-show-closed" => {
656                // The daemon-backed show/hide-closed toggle (#1301). Setting it
657                // bumps the change-notify, so every subscribed window re-pushes a
658                // snapshot carrying the new `show_closed` — reliable cross-window
659                // sync `context.globalState` could not do.
660                let show_closed = payload
661                    .get("show_closed")
662                    .and_then(Value::as_bool)
663                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
664                self.registry.set_show_closed(show_closed);
665                Ok(json!({ "ok": true }))
666            }
667            "open" => {
668                // Focus (or open — VS Code reuses an already-open window) an
669                // arbitrary worktree folder supplied by a socket client, reusing
670                // the tray's launcher path: `focus_window` resolves the launcher
671                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
672                // the absolute-existing-directory guard (which also blocks a
673                // `-`-leading path being parsed by `code` as a flag). This is the
674                // one op a socket *writer* can use to spawn `code`; see the
675                // ADR-0040 threat model (#1266).
676                let path = require_str(&payload, "path", "open")?;
677                focus_window(Path::new(path))?;
678                Ok(json!({ "ok": true }))
679            }
680            "close" => {
681                // Close a worktree's window and (for a linked worktree)
682                // **delete** it. Destructive, so all git logic stays in the
683                // daemon (git2, never a shell) and the main working tree is
684                // refused defensively — the UI gating is not the only guard.
685                // See ADR-0049 and docs/worktrees-service.md.
686                let req: CloseRequest =
687                    serde_json::from_value(payload).context("invalid `close` payload")?;
688                self.close(req).await
689            }
690            other => bail!("unknown worktrees op: {other}"),
691        }
692    }
693
694    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
695        // The single streaming op: a live push of the repo/worktree `tree`
696        // snapshot. Every other op falls through to the request→reply `handle`.
697        if op != "subscribe" {
698            return None;
699        }
700        Some(Box::new(WorktreesStream {
701            // Every stream reads through the one shared cache, so N windows
702            // sampling the same tick build the tree once, not N times (#1303).
703            cache: self.tree_cache.clone(),
704            // Capture the change source *now* — before the server takes its
705            // initial snapshot — so a change racing that snapshot still wakes us.
706            changes: self.registry.subscribe_changes(),
707        }))
708    }
709
710    fn menu(&self) -> MenuSnapshot {
711        // Serve the snapshot the background task maintains off the main thread;
712        // fall back to a one-off inline compute only before the first refresh
713        // lands (or with no runtime — the unit tests). Never blocks on git here
714        // in the daemon, honouring the trait's "cheap, must not block" contract.
715        let cached = self
716            .menu_cache
717            .lock()
718            .unwrap_or_else(PoisonError::into_inner)
719            .clone();
720        let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
721        MenuSnapshot {
722            title: SUBMENU_TITLE.to_string(),
723            items,
724        }
725    }
726
727    async fn menu_action(&self, action_id: &str) -> Result<()> {
728        if let Some(key) = action_id.strip_prefix("focus:") {
729            // The registry resolves the folder under its own lock and clones it
730            // out, so the mutex is never held across the process launch.
731            let folder = self
732                .registry
733                .first_folder(key)
734                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
735            focus_window(&folder)?;
736            return Ok(());
737        }
738        bail!("unknown worktrees menu action: {action_id}")
739    }
740
741    async fn status(&self) -> ServiceStatus {
742        let entries = self.registry.list();
743        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
744        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
745        let windows = enriched_windows(entries).await;
746        ServiceStatus {
747            name: SERVICE_NAME.to_string(),
748            healthy: true,
749            summary,
750            detail: json!({ "windows": windows }),
751        }
752    }
753
754    async fn shutdown(&self) {
755        // Stop the background menu-refresh task; the registry itself is in-memory
756        // with nothing to drain or persist. Take the task out from under the lock
757        // first so the `std::Mutex` is never held across the `.await`.
758        let task = self
759            .refresh
760            .lock()
761            .unwrap_or_else(PoisonError::into_inner)
762            .take();
763        if let Some(task) = task {
764            task.token.cancel();
765            let _ = task.handle.await;
766        }
767        // Same discipline for the PR badge poller (#1337): take it out from under
768        // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
769        let poller = self
770            .poller
771            .lock()
772            .unwrap_or_else(PoisonError::into_inner)
773            .take();
774        if let Some(poller) = poller {
775            poller.token.cancel();
776            let _ = poller.handle.await;
777        }
778    }
779}
780
781/// Extracts a required string `field` from an op payload, erroring with the op
782/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
783/// (`key`) and `open` (`path`).
784fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
785    payload
786        .get(field)
787        .and_then(Value::as_str)
788        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
789}
790
791/// The live git state of a worktree folder: the checked-out branch and how far
792/// it has diverged from its upstream. Computed on read from the on-disk repo
793/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
794/// snapshot taken at registration.
795///
796/// Every field is optional and degrades independently: a folder that is not a
797/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
798/// listed — just without the fields it cannot supply. The `skip_serializing_if`
799/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
800/// omitting each absent field on the wire.
801#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
802struct GitStatus {
803    /// The checked-out branch, or `None` when detached or not in a repo.
804    #[serde(skip_serializing_if = "Option::is_none")]
805    branch: Option<String>,
806    /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
807    /// even on a detached HEAD, which has a commit but no branch. Rides the
808    /// streamed snapshot so a new commit is a real delta the server's diff cannot
809    /// drop — without it, a push serialises byte-identically and no client
810    /// re-renders (#1337).
811    #[serde(skip_serializing_if = "Option::is_none")]
812    head_sha: Option<String>,
813    /// The commit the branch's configured upstream ref points at, or `None`
814    /// without an upstream (or when detached, unborn, or not in a repo). Rides
815    /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
816    /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
817    /// field — `head_sha` included — byte-identical, so without this the frame
818    /// serialised the same, the server's diff dropped it, and the lazily-fetched
819    /// ahead/behind was never re-asked (#1344).
820    #[serde(skip_serializing_if = "Option::is_none")]
821    upstream_sha: Option<String>,
822    /// Commits the branch is ahead of its upstream (`None` without an upstream).
823    #[serde(skip_serializing_if = "Option::is_none")]
824    ahead: Option<usize>,
825    /// Commits the branch is behind its upstream (`None` without an upstream).
826    #[serde(skip_serializing_if = "Option::is_none")]
827    behind: Option<usize>,
828    /// The main repository's directory name — the parent repo for a linked
829    /// worktree, the checkout's own directory otherwise. Derived from git's
830    /// common dir so a worktree names the repo it belongs to rather than its
831    /// worktree-folder basename. `None` when not in a repo.
832    #[serde(skip_serializing_if = "Option::is_none")]
833    main_repo: Option<String>,
834    /// Whether the enriched folder is a **linked** git worktree rather than the
835    /// repository's main working tree. Omitted (false) for a normal checkout.
836    #[serde(skip_serializing_if = "is_false")]
837    is_worktree: bool,
838}
839
840/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
841/// field is dropped on the wire unless set — keeping older clients byte-identical
842/// (the protocol's forward-compatibility convention).
843#[allow(clippy::trivially_copy_pass_by_ref)]
844fn is_false(b: &bool) -> bool {
845    !*b
846}
847
848/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
849/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
850/// op and the tray menu, both bounded to the (few) open windows, where the extra
851/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
852/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
853fn git_status(folder: &Path) -> GitStatus {
854    git_status_impl(folder, true)
855}
856
857/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
858/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
859/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
860/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
861/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
862/// fields stay `None`, so they are omitted on the wire exactly as for a branch
863/// with no upstream.
864fn git_status_cheap(folder: &Path) -> GitStatus {
865    git_status_impl(folder, false)
866}
867
868/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
869/// repository that contains `folder` — so a subdirectory or a linked worktree both
870/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
871/// divergence. Every failure mode degrades to an empty status rather than
872/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
873fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
874    let Ok(repo) = Repository::discover(folder) else {
875        return GitStatus::default();
876    };
877    // Repo identity applies even when HEAD is unborn or detached, so a worktree
878    // still names its parent repo (and is flagged as a worktree) in those states.
879    let base = GitStatus {
880        main_repo: main_repo_name(repo.commondir()),
881        is_worktree: repo.is_worktree(),
882        ..GitStatus::default()
883    };
884    let Ok(head) = repo.head() else {
885        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
886        return base;
887    };
888    // Resolved here — before the branch filter below, so a detached HEAD still
889    // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
890    // a refs read: no revwalk and no object lookup, so unlike the divergence walk
891    // it is cheap enough for the streamed snapshot's every-worktree-every-tick
892    // rebuild (#1306's bar).
893    let base = GitStatus {
894        head_sha: head.target().map(|oid| oid.to_string()),
895        ..base
896    };
897    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
898    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
899    // name — degrades to no branch through this one path.
900    let Some(name) = head
901        .shorthand()
902        .ok()
903        .filter(|_| head.is_branch())
904        .map(str::to_string)
905    else {
906        return base;
907    };
908    // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
909    // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
910    // below costs the cheap path nothing, and is what gives it a handle to
911    // resolve the upstream from.
912    let branch = git2::Branch::wrap(head);
913    // Unlike the divergence walk, this rides both paths: it is what makes a push
914    // a visible delta (#1344).
915    let upstream_sha = upstream_target(&branch);
916    // The divergence walk is the dominant per-worktree cost, so the cheap path
917    // skips it and leaves ahead/behind absent.
918    let (ahead, behind) = if with_ahead_behind {
919        match upstream_ahead_behind(&repo, &branch) {
920            Some((ahead, behind)) => (Some(ahead), Some(behind)),
921            None => (None, None),
922        }
923    } else {
924        (None, None)
925    };
926    GitStatus {
927        branch: Some(name),
928        upstream_sha,
929        ahead,
930        behind,
931        ..base
932    }
933}
934
935/// The commit `branch`'s configured upstream ref points at, or `None` when it
936/// tracks no upstream (or the ref is unresolvable).
937///
938/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
939/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
940/// refs read, but still **no revwalk and no object lookup**, which is the bar
941/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
942/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
943/// same OID, so it is proven reachable.
944fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
945    Some(branch.upstream().ok()?.get().target()?.to_string())
946}
947
948/// The ahead/behind divergence of `folder`'s checked-out branch versus its
949/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
950/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
951/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
952/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
953/// sync indicator.
954fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
955    let repo = Repository::discover(folder).ok()?;
956    let head = repo.head().ok()?;
957    if !head.is_branch() {
958        return None;
959    }
960    let branch = git2::Branch::wrap(head);
961    upstream_ahead_behind(&repo, &branch)
962}
963
964/// The main repository's directory name from git's common dir. For the usual
965/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
966/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
967/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
968/// no name can be derived.
969fn main_repo_name(commondir: &Path) -> Option<String> {
970    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
971    if file_name == ".git" {
972        // Normal layout: the repo is the directory that contains `.git`.
973        commondir
974            .parent()
975            .and_then(Path::file_name)
976            .map(|n| n.to_string_lossy().into_owned())
977    } else {
978        // A bare repo: use its own directory name, without any `.git` suffix.
979        Some(
980            file_name
981                .strip_suffix(".git")
982                .unwrap_or(&file_name)
983                .to_string(),
984        )
985    }
986}
987
988/// Ahead/behind commit counts of `branch` versus its configured upstream, or
989/// `None` when the branch tracks no upstream (or either tip is unresolvable).
990fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
991    let upstream = branch.upstream().ok()?;
992    let local_oid = branch.get().target()?;
993    let upstream_oid = upstream.get().target()?;
994    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
995}
996
997/// The wire shape of an enriched window: the stored entry fields plus the
998/// daemon-computed git state, flattened into one JSON object. Serializing
999/// through a single struct (rather than mutating a `Value`) keeps every present
1000/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
1001/// the absent git fields — no manual per-field insertion.
1002#[derive(Serialize)]
1003struct EnrichedEntry<'a> {
1004    #[serde(flatten)]
1005    entry: &'a WindowEntry,
1006    #[serde(flatten)]
1007    git: GitStatus,
1008}
1009
1010/// Serializes a registry entry and folds in the live [`git_status`] of its
1011/// primary (first) folder, producing the JSON object served on the wire
1012/// (`list`/`status`) and read by the extension UI. Only the primary folder is
1013/// enriched — it is the one the table shows and the "focus" action opens.
1014fn enriched_entry(entry: &WindowEntry) -> Value {
1015    let git = entry
1016        .folders
1017        .first()
1018        .map(|folder| git_status(folder))
1019        .unwrap_or_default();
1020    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
1021}
1022
1023/// Enriches a batch of entries with their git state on a blocking thread, since
1024/// `git2` does synchronous disk I/O and this runs inside the async control-socket
1025/// handler. A join failure degrades to an empty list rather than erroring.
1026async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
1027    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
1028        .await
1029        .unwrap_or_default()
1030}
1031
1032// --- Repo/worktree tree (#1265) ----------------------------------------------
1033
1034/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
1035/// Present on a repo in the `tree` payload only for `github.com` remotes; a
1036/// non-GitHub (or remote-less) repo omits it.
1037#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1038struct GithubIdentity {
1039    /// The repository owner (user or org) — the first path segment.
1040    owner: String,
1041    /// The repository name, with any `.git` suffix stripped.
1042    name: String,
1043}
1044
1045/// One worktree of a repository in the `tree` payload: its path, live git state,
1046/// whether it is the main working tree, and whether a VS Code window currently
1047/// has it open (with that window's key, for the focus action). Optional git
1048/// fields degrade independently, exactly like [`GitStatus`].
1049///
1050/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
1051/// the dominant per-worktree cost when computed eagerly for every worktree on
1052/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
1053/// worktrees a client actually shows (#1306).
1054///
1055/// The two **OIDs** the divergence is computed from — `head_sha` and
1056/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
1057/// refs read rather than a commit-graph walk, and between them they are what makes
1058/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
1059/// re-ask for the counts it left behind.
1060#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1061struct TreeWorktree {
1062    /// Absolute path to the worktree's working directory.
1063    path: String,
1064    /// The checked-out branch, or `None` when detached or unborn.
1065    #[serde(skip_serializing_if = "Option::is_none")]
1066    branch: Option<String>,
1067    /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
1068    /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
1069    /// commit a visible delta, so a push re-renders instead of being dropped by the
1070    /// server's snapshot diff (#1337).
1071    #[serde(skip_serializing_if = "Option::is_none")]
1072    head_sha: Option<String>,
1073    /// The commit the branch's upstream ref points at, or `None` without an
1074    /// upstream. The push counterpart of `head_sha`: a push moves only
1075    /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
1076    /// making the snapshot a real delta the server's diff cannot drop, which is
1077    /// what re-fetches the lazy ahead/behind (#1344).
1078    #[serde(skip_serializing_if = "Option::is_none")]
1079    upstream_sha: Option<String>,
1080    /// Whether this is the repository's main working tree (vs a linked worktree).
1081    is_main: bool,
1082    /// Whether a live VS Code window currently has this worktree open.
1083    open: bool,
1084    /// The open window's registry key, when `open` — the handle a focus action
1085    /// resolves. Absent for a worktree with no open window.
1086    #[serde(skip_serializing_if = "Option::is_none")]
1087    window_key: Option<String>,
1088    /// The open PR whose head is this worktree's branch, with its CI verdict
1089    /// (#1337). Resolved by the daemon's background poller and folded on as the
1090    /// snapshot is built, so every open window sees the same live state without
1091    /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
1092    /// with no open PR, or until the first poll lands.
1093    #[serde(skip_serializing_if = "Option::is_none")]
1094    pr: Option<PrBadge>,
1095}
1096
1097/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
1098/// derived from the distinct open windows; a repo leaves the tree when its last
1099/// window closes (the open-window-derived model, ADR-0040 / #1264).
1100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1101struct TreeRepo {
1102    /// The main repository's directory name (see [`main_repo_name`]).
1103    main_repo: String,
1104    /// The GitHub identity of `origin`, when it is a `github.com` remote.
1105    #[serde(skip_serializing_if = "Option::is_none")]
1106    github: Option<GithubIdentity>,
1107    /// Absolute path to the main working tree — the repo's root.
1108    root: String,
1109    /// Every worktree of the repo: the main working tree first, then linked
1110    /// worktrees sorted by path.
1111    worktrees: Vec<TreeWorktree>,
1112}
1113
1114/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
1115/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
1116/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
1117/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
1118/// slashes are stripped; anything with an empty or extra path segment is
1119/// rejected (best-effort, never panics).
1120fn github_identity(url: &str) -> Option<GithubIdentity> {
1121    let url = url.trim();
1122    // Reduce every supported form to the `owner/name…` tail after the host.
1123    let rest = [
1124        "https://github.com/",
1125        "http://github.com/",
1126        "ssh://git@github.com/",
1127        "git://github.com/",
1128        "git@github.com:",
1129    ]
1130    .iter()
1131    .find_map(|prefix| url.strip_prefix(prefix))?;
1132    let rest = rest.strip_suffix(".git").unwrap_or(rest);
1133    let rest = rest.trim_end_matches('/');
1134    let mut parts = rest.splitn(2, '/');
1135    let owner = parts.next()?.trim();
1136    let name = parts.next()?.trim();
1137    // A well-formed identity has exactly two non-empty segments.
1138    if owner.is_empty() || name.is_empty() || name.contains('/') {
1139        return None;
1140    }
1141    Some(GithubIdentity {
1142        owner: owner.to_string(),
1143        name: name.to_string(),
1144    })
1145}
1146
1147/// The GitHub identity of `repo`: `origin`'s URL first, else the first
1148/// `github.com` remote found. `None` when no remote is a GitHub remote.
1149fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
1150    if let Ok(origin) = repo.find_remote("origin") {
1151        if let Some(id) = origin.url().ok().and_then(github_identity) {
1152            return Some(id);
1153        }
1154    }
1155    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
1156    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
1157    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
1158    let names = repo.remotes().ok();
1159    names
1160        .iter()
1161        .flat_map(|arr| arr.iter())
1162        .flatten()
1163        .flatten()
1164        .filter_map(|name| repo.find_remote(name).ok())
1165        .find_map(|remote| remote.url().ok().and_then(github_identity))
1166}
1167
1168/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
1169/// falling back to the path as-given when it cannot be canonicalized (e.g. it
1170/// no longer exists) so the join still degrades gracefully.
1171fn canonical(path: &Path) -> PathBuf {
1172    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1173}
1174
1175/// Indexes the open windows by canonicalized workspace-folder path → window key,
1176/// so a worktree path can be joined back to the window (if any) that has it open.
1177/// The first window wins a shared folder; `entries` arrive in a deterministic
1178/// (repo, key) order, so the choice is stable.
1179fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
1180    let mut index = HashMap::new();
1181    for entry in entries {
1182        for folder in &entry.folders {
1183            index
1184                .entry(canonical(folder))
1185                .or_insert_with(|| entry.key.clone());
1186        }
1187    }
1188    index
1189}
1190
1191/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
1192/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
1193/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
1194/// by the caller from the enumeration (main working tree vs linked).
1195fn worktree_entry(
1196    path: &Path,
1197    is_main: bool,
1198    open_index: &HashMap<PathBuf, String>,
1199) -> TreeWorktree {
1200    let status = git_status_cheap(path);
1201    let window_key = open_index.get(&canonical(path)).cloned();
1202    TreeWorktree {
1203        path: path.display().to_string(),
1204        branch: status.branch,
1205        head_sha: status.head_sha,
1206        upstream_sha: status.upstream_sha,
1207        is_main,
1208        open: window_key.is_some(),
1209        window_key,
1210        // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
1211        // identity — known one level up, in `repo_tree`.
1212        pr: None,
1213    }
1214}
1215
1216/// Folds the poller's cached PR badges onto each worktree of each repo (#1337).
1217///
1218/// Runs after [`build_tree`] because a badge is keyed by (repo GitHub identity,
1219/// branch) and the identity is only known once the repo is assembled. Purely a
1220/// cache read — no I/O, no network — so it is safe on the snapshot's hot path. A
1221/// non-GitHub repo, a branchless worktree, or an unresolved branch simply keeps
1222/// `pr: None` and renders nothing.
1223///
1224/// A verdict computed for a **different commit** than the worktree has checked out
1225/// is downgraded to pending here rather than shown as-is. That is what makes a push
1226/// invalidate the badge the moment it happens: the cache still holds the previous
1227/// commit's verdict, and this fold — which runs on every snapshot — notices without
1228/// waiting for a poll. Without it the previous head's `✓` stands until the poller
1229/// next runs, which is up to the full backoff.
1230fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
1231    for repo in repos {
1232        let Some(github) = repo.github.clone() else {
1233            continue;
1234        };
1235        for worktree in &mut repo.worktrees {
1236            let Some(branch) = &worktree.branch else {
1237                continue;
1238            };
1239            let Some(mut badge) = pr_cache.get(&github.owner, &github.name, branch) else {
1240                continue;
1241            };
1242            if badge.is_stale_for(worktree.head_sha.as_deref()) {
1243                badge.checks = PrCheckState::Pending;
1244            }
1245            worktree.pr = Some(badge);
1246        }
1247    }
1248}
1249
1250/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
1251/// handle discovered from one of its folders. Opens the **main** repo from the
1252/// shared common dir's parent so the main working tree and every linked worktree
1253/// are enumerated regardless of which one seeded the discovery. `None` for a
1254/// bare or otherwise root-less repo (no working tree to show).
1255fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
1256    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
1257    // linked worktrees; its parent is the main working tree.
1258    let commondir = canonical(discovered.commondir());
1259    let main_root = commondir.parent()?.to_path_buf();
1260    let main_repo = Repository::open(&main_root).ok()?;
1261
1262    // Main working tree first.
1263    let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
1264    // Then every linked worktree, sorted by path for deterministic output. The
1265    // `StringArray` of names is bound so `iter()` can borrow it (only
1266    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
1267    // worktree is skipped.
1268    let names = main_repo.worktrees().ok();
1269    let mut linked: Vec<PathBuf> = names
1270        .iter()
1271        .flat_map(|arr| arr.iter())
1272        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
1273        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
1274        .filter_map(|name| main_repo.find_worktree(name).ok())
1275        .map(|wt| wt.path().to_path_buf())
1276        .collect();
1277    linked.sort();
1278    worktrees.extend(
1279        linked
1280            .iter()
1281            .map(|path| worktree_entry(path, false, open_index)),
1282    );
1283
1284    Some(TreeRepo {
1285        main_repo: main_repo_name(&commondir)?,
1286        github: remote_github_identity(&main_repo),
1287        root: main_root.display().to_string(),
1288        worktrees,
1289    })
1290}
1291
1292/// Resolves the seed `folders` to their distinct repositories and enumerates
1293/// each repo's worktrees. Dedupes repos by their common dir (shared across a
1294/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
1295/// is not in a git repo is skipped. Pure blocking git I/O — call it via
1296/// [`tree_repos`], never under the registry lock.
1297fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
1298    let open_index = open_window_index(&windows);
1299    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
1300    for folder in &folders {
1301        let Ok(repo) = Repository::discover(folder) else {
1302            continue;
1303        };
1304        let key = canonical(repo.commondir());
1305        if repos.contains_key(&key) {
1306            continue;
1307        }
1308        if let Some(tree) = repo_tree(&repo, &open_index) {
1309            repos.insert(key, tree);
1310        }
1311    }
1312    repos.into_values().collect()
1313}
1314
1315/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
1316/// does synchronous disk I/O and this runs inside the async control-socket
1317/// handler), returning the serialized `repos` array. A join failure degrades to
1318/// an empty list rather than erroring, matching [`enriched_windows`].
1319async fn tree_repos(
1320    folders: Vec<PathBuf>,
1321    windows: Vec<WindowEntry>,
1322    pr_cache: Arc<PrStatusCache>,
1323) -> Vec<Value> {
1324    tokio::task::spawn_blocking(move || {
1325        let mut repos = build_tree(folders, windows);
1326        fold_pr_badges(&mut repos, &pr_cache);
1327        repos
1328            .iter()
1329            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
1330            .collect()
1331    })
1332    .await
1333    .unwrap_or_default()
1334}
1335
1336// --- Lazy ahead/behind (#1306) -----------------------------------------------
1337
1338/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
1339/// returning a JSON object keyed by the **requested** path string:
1340/// `{ "<path>": { "ahead": n, "behind": m }, … }`. A path with no upstream (or that
1341/// is not a repo / is detached) is **omitted** — the client renders it without a
1342/// sync indicator, exactly as the tree does for an absent `ahead`/`behind`.
1343///
1344/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
1345/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
1346/// shows (the extension on expand), not for every worktree on every tick. The git
1347/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
1348/// degrades to an empty object rather than erroring.
1349async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
1350    tokio::task::spawn_blocking(move || {
1351        let mut results = serde_json::Map::new();
1352        for path in paths {
1353            if let Some((ahead, behind)) = folder_ahead_behind(&path) {
1354                results.insert(
1355                    path.display().to_string(),
1356                    json!({ "ahead": ahead, "behind": behind }),
1357                );
1358            }
1359        }
1360        Value::Object(results)
1361    })
1362    .await
1363    .unwrap_or_else(|_| json!({}))
1364}
1365
1366// --- Push subscription (#1267) -----------------------------------------------
1367
1368/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
1369/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
1370/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
1371/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
1372/// type only has to (a) relay the registry's change-notify and (b) read the
1373/// tree snapshot on demand.
1374///
1375/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
1376/// is built at most once per tick and fanned out, rather than each stream
1377/// rebuilding the identical tree. This type holds only cheap handles — a clone
1378/// of the shared cache and its own change-notify receiver.
1379struct WorktreesStream {
1380    /// The shared coalescing cache the snapshot is read through, so every
1381    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
1382    cache: Arc<TreeSnapshotCache>,
1383    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
1384    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
1385    /// server's diff drops any snapshot that ends up identical.
1386    changes: watch::Receiver<u64>,
1387}
1388
1389#[async_trait]
1390impl ServiceStream for WorktreesStream {
1391    async fn changed(&mut self) {
1392        // `watch::Receiver::changed` marks the newest version seen, so a burst of
1393        // bumps collapses into a single wakeup. If every sender is gone (the
1394        // registry — and thus the daemon — is tearing down) it returns `Err`;
1395        // park instead of returning, so this arm can never spin the server's
1396        // `select!` (the tick and shutdown arms still drive teardown).
1397        if self.changes.changed().await.is_err() {
1398            std::future::pending::<()>().await;
1399        }
1400    }
1401
1402    async fn snapshot(&self) -> Value {
1403        // Read through the shared coalescing cache. The value is built by the
1404        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
1405        // live push agree byte-for-byte — but here it is built once per tick and
1406        // shared across every subscriber rather than rebuilt per stream (#1303).
1407        self.cache.snapshot().await
1408    }
1409}
1410
1411/// A coalescing cache for the global tree snapshot (#1303).
1412///
1413/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
1414/// server re-samples each on its own `STREAM_TICK` and on every registry change
1415/// — so with N windows the *identical* global tree was being built N times per
1416/// tick. This cache collapses that to **one** build: all streams share it, and
1417/// it rebuilds at most once per `ttl` (the stream tick) per registry
1418/// change-generation.
1419///
1420/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
1421/// exactly as before:
1422/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
1423///   still matches — a `register`/`unregister`/toggle bumps it and forces a
1424///   fresh build, so subscribers never see a stale visible set; and
1425/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
1426///   branch switch, new commits), which fires no registry event, still surfaces
1427///   within one tick.
1428///
1429/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
1430/// callers, so a burst of N streams waking on the same tick/change performs one
1431/// build while the rest wait and read the shared result. The one-shot `tree` op
1432/// bypasses this and computes fresh — it is a rare manual refresh, not part of
1433/// the per-tick fan-out.
1434struct TreeSnapshotCache {
1435    /// The registry every snapshot is built from, and whose change-generation
1436    /// gates cache reuse.
1437    registry: Arc<WorktreesRegistry>,
1438    /// PR badges folded onto each worktree as the snapshot is built (#1337).
1439    /// Written by the background poller; read here. A miss simply omits `pr`.
1440    pr_cache: Arc<PrStatusCache>,
1441    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
1442    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
1443    /// coalesced build runs at most once per tick; tests inject a shorter value.
1444    ttl: Duration,
1445    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
1446    /// because it is deliberately held across the `.await` of the git
1447    /// enumeration, so concurrent callers serialize onto one build rather than
1448    /// each computing their own.
1449    state: AsyncMutex<Option<CachedTree>>,
1450    /// How many times the tree was actually (re)built — so tests can assert the
1451    /// coalescing collapses an N-stream burst into one build. Cheap and always
1452    /// maintained; only read under `#[cfg(test)]`.
1453    computes: AtomicU64,
1454}
1455
1456/// One cached tree snapshot: the shared value plus the two freshness stamps
1457/// [`TreeSnapshotCache`] checks before reusing it.
1458struct CachedTree {
1459    /// The registry change-generation captured *before* the build, so a change
1460    /// racing the build advances the generation and the next read rebuilds
1461    /// (conservative: it may rebuild once needlessly, but never serves stale).
1462    generation: u64,
1463    /// When the value was built, for the `ttl` staleness check.
1464    computed_at: Instant,
1465    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
1466    /// subscriber by cloning the `Arc`'s inner value.
1467    value: Arc<Value>,
1468}
1469
1470impl TreeSnapshotCache {
1471    /// Creates a cache over `registry` with the default TTL — the server's
1472    /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
1473    /// build runs at most once per tick.
1474    fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
1475        Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
1476    }
1477
1478    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
1479    /// long) freshness window without waiting a real tick.
1480    fn with_ttl(
1481        registry: Arc<WorktreesRegistry>,
1482        pr_cache: Arc<PrStatusCache>,
1483        ttl: Duration,
1484    ) -> Self {
1485        Self {
1486            registry,
1487            pr_cache,
1488            ttl,
1489            state: AsyncMutex::new(None),
1490            computes: AtomicU64::new(0),
1491        }
1492    }
1493
1494    /// The current tree snapshot, built at most once per `ttl` per registry
1495    /// change-generation and shared across all callers. See the type docs for
1496    /// the freshness and single-flight semantics.
1497    async fn snapshot(&self) -> Value {
1498        // Hold the lock across the whole check-and-build so concurrent callers
1499        // serialize onto one build (single-flight); reading the generation here
1500        // (before the build) means a change racing the build forces the *next*
1501        // read to rebuild rather than serving this now-stale value.
1502        let mut state = self.state.lock().await;
1503        let generation = self.registry.change_generation();
1504        // Reuse the cached value only while it matches the current generation
1505        // *and* is within the TTL; either failing forces a rebuild.
1506        let fresh = state.as_ref().and_then(|cached| {
1507            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
1508                .then(|| Arc::clone(&cached.value))
1509        });
1510        let value = if let Some(value) = fresh {
1511            value
1512        } else {
1513            let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
1514            self.computes.fetch_add(1, Ordering::Relaxed);
1515            *state = Some(CachedTree {
1516                generation,
1517                computed_at: Instant::now(),
1518                value: Arc::clone(&value),
1519            });
1520            value
1521        };
1522        // Release the lock before the (deeper) clone of the shared value out.
1523        drop(state);
1524        (*value).clone()
1525    }
1526
1527    /// How many times the tree was actually built — the coalescing assertion in
1528    /// tests (N reads within one tick/generation should build once).
1529    #[cfg(test)]
1530    fn compute_count(&self) -> u64 {
1531        self.computes.load(Ordering::Relaxed)
1532    }
1533}
1534
1535/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
1536/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
1537/// (the seed folders to derive repos from, and the live windows to join on) and
1538/// a lock-free read of the toggle, then the git enumeration/enrichment off the
1539/// lock on a blocking thread inside [`tree_repos`].
1540async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
1541    let folders = registry.open_folders();
1542    let windows = registry.list();
1543    let show_closed = registry.show_closed();
1544    json!({
1545        "repos": tree_repos(folders, windows, pr_cache).await,
1546        "show_closed": show_closed,
1547    })
1548}
1549
1550/// A short human name for a window: its repo, else its first folder's basename,
1551/// else a placeholder.
1552fn display_name(entry: &WindowEntry) -> String {
1553    if let Some(repo) = &entry.repo {
1554        return repo.clone();
1555    }
1556    if let Some(folder) = entry.folders.first() {
1557        return folder.file_name().map_or_else(
1558            || folder.display().to_string(),
1559            |n| n.to_string_lossy().into_owned(),
1560        );
1561    }
1562    "(no folder)".to_string()
1563}
1564
1565/// Separator between the repo name and branch for a normal working tree.
1566const REPO_SEP: char = '·';
1567/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
1568/// line is distinguishable at a glance from its parent repo's main checkout.
1569const WORKTREE_SEP: char = '⑂';
1570
1571/// The full tray item list for a window set: the "No open windows" placeholder
1572/// when empty, else one line per window via [`window_menu_items`]. Does the git
1573/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
1574/// background refresh task — and inline only as a cold-start fallback in `menu`.
1575fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
1576    if entries.is_empty() {
1577        vec![MenuItem::Label("No open windows".to_string())]
1578    } else {
1579        window_menu_items(entries)
1580    }
1581}
1582
1583/// Builds the tray items for a non-empty window list: **one clickable line per
1584/// window** whose label carries the live git state and whose click focuses that
1585/// window. A window with no workspace folder has nothing for `code` to open, so
1586/// it stays a non-clickable status line. The labels read each worktree from disk
1587/// (via [`window_label`]) — cheap for a realistic window count and consistent
1588/// with reap-on-read.
1589fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
1590    entries
1591        .iter()
1592        .map(|entry| {
1593            let label = window_label(entry);
1594            if entry.folders.is_empty() {
1595                MenuItem::Label(label)
1596            } else {
1597                MenuItem::Action(MenuAction {
1598                    id: format!("focus:{}", entry.key),
1599                    label,
1600                    enabled: true,
1601                })
1602            }
1603        })
1604        .collect()
1605}
1606
1607/// The tray label for one window: the **main repository** name, then live branch
1608/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
1609/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
1610/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
1611/// that is not a repo falls back to its reported title.
1612fn window_label(entry: &WindowEntry) -> String {
1613    let status = entry
1614        .folders
1615        .first()
1616        .map(|folder| git_status(folder))
1617        .unwrap_or_default();
1618    // Prefer the git-derived main repo so a linked worktree names its parent
1619    // repository rather than its worktree-folder basename.
1620    let name = status
1621        .main_repo
1622        .clone()
1623        .unwrap_or_else(|| display_name(entry));
1624    if let Some(branch) = &status.branch {
1625        let sep = if status.is_worktree {
1626            WORKTREE_SEP
1627        } else {
1628            REPO_SEP
1629        };
1630        return match sync_indicator(status.ahead, status.behind) {
1631            Some(sync) => format!("{name} {sep} {branch} {sync}"),
1632            None => format!("{name} {sep} {branch}"),
1633        };
1634    }
1635    // No git branch (not a repo / detached): fall back to the reported title.
1636    match &entry.title {
1637        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
1638        _ => name,
1639    }
1640}
1641
1642/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
1643/// has no upstream to compare against.
1644fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
1645    match (ahead, behind) {
1646        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
1647        _ => None,
1648    }
1649}
1650
1651/// Well-known absolute locations for the VS Code launcher, tried in order so a
1652/// daemon running under launchd (with a minimal `PATH`) still finds it.
1653const CODE_BINARY_CANDIDATES: &[&str] = &[
1654    "/usr/local/bin/code",
1655    "/opt/homebrew/bin/code",
1656    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
1657    "/usr/bin/code",
1658];
1659
1660/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
1661/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
1662/// with the sessions service's tray "focus" action, which resolves a session to
1663/// its VS Code window folder and opens it through this same guarded launcher.
1664pub(crate) fn focus_window(folder: &Path) -> Result<()> {
1665    focus_window_with(&resolve_code_binary(), folder)
1666}
1667
1668/// Spawns `program` on `folder` after validating the folder. Split out from
1669/// [`focus_window`] so the validation and spawn paths are testable with an
1670/// explicit launcher (no environment or installed-editor dependency).
1671///
1672/// Best-effort and non-blocking: the spawned child is reaped on a detached
1673/// thread so a long-lived daemon does not accumulate zombies one per focus.
1674fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
1675    // The tray path passes an absolute workspace folder, but the socket `open`
1676    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
1677    // real check there, not just an assertion: requiring an absolute path also
1678    // rules out a `-`-leading path being parsed by `code` as a flag.
1679    if !folder.is_absolute() {
1680        bail!(
1681            "refusing to focus a non-absolute folder path: {}",
1682            folder.display()
1683        );
1684    }
1685    if !folder.is_dir() {
1686        bail!("worktree folder no longer exists: {}", folder.display());
1687    }
1688    // Detach the launcher's stdio so its output never interleaves into the
1689    // long-lived daemon's own stdout/stderr (or the test harness's).
1690    let child = Command::new(program)
1691        .arg(folder)
1692        .stdin(Stdio::null())
1693        .stdout(Stdio::null())
1694        .stderr(Stdio::null())
1695        .spawn()
1696        .with_context(|| {
1697            format!(
1698                "failed to launch `{}` to focus {}",
1699                program.display(),
1700                folder.display()
1701            )
1702        })?;
1703    // Reap the child without blocking so it never lingers as a zombie.
1704    std::thread::spawn(move || {
1705        let mut child = child;
1706        let _ = child.wait();
1707    });
1708    Ok(())
1709}
1710
1711/// Resolves the VS Code launcher from the real environment: the
1712/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
1713/// `code` on `PATH`. The pure resolution logic lives in
1714/// [`resolve_code_binary_from`] for testing.
1715fn resolve_code_binary() -> PathBuf {
1716    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
1717}
1718
1719/// Pure launcher resolution: `env_override` wins; otherwise the first existing
1720/// `candidate`; otherwise bare `code`.
1721fn resolve_code_binary_from(
1722    env_override: Option<std::ffi::OsString>,
1723    candidates: &[&str],
1724) -> PathBuf {
1725    if let Some(path) = env_override {
1726        return PathBuf::from(path);
1727    }
1728    for candidate in candidates {
1729        let path = Path::new(candidate);
1730        if path.exists() {
1731            return path.to_path_buf();
1732        }
1733    }
1734    PathBuf::from("code")
1735}
1736
1737// --- Close op (#1277) --------------------------------------------------------
1738
1739/// The `close` op payload: close a worktree's window and (for a linked worktree)
1740/// delete it. Symmetric to `open`, but destructive, so it carries the
1741/// two-phase-confirm and self-close routing fields.
1742#[derive(Debug, Clone, Deserialize)]
1743struct CloseRequest {
1744    /// Absolute path of the target worktree's working directory.
1745    path: PathBuf,
1746    /// The requesting window's key, so a self-close (`requester_key` owns the
1747    /// target) removes-then-replies and lets the extension close its own window,
1748    /// rather than waiting on a window that is blocked awaiting this reply.
1749    #[serde(default)]
1750    requester_key: Option<String>,
1751    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
1752    /// only close its window (main "Close Window"). A delete is refused on the
1753    /// main working tree regardless of this flag.
1754    #[serde(default)]
1755    remove: bool,
1756    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
1757    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
1758    #[serde(default)]
1759    confirmed: bool,
1760}
1761
1762/// One risk or informational note in a [`SafetyReport`]: a machine-readable
1763/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
1764/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
1765/// commits that survive because the branch is kept).
1766#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1767struct Note {
1768    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
1769    kind: String,
1770    /// A human-readable one-line explanation for the confirm dialog.
1771    detail: String,
1772}
1773
1774impl Note {
1775    fn new(kind: &str, detail: impl Into<String>) -> Self {
1776        Self {
1777            kind: kind.to_string(),
1778            detail: detail.into(),
1779        }
1780    }
1781}
1782
1783/// The phase-1 safety report the extension reads to decide whether to prompt.
1784/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
1785/// entry → show a modal confirm listing them.
1786#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1787struct SafetyReport {
1788    /// Whether the target is a deletable (linked) worktree at all — `false` for
1789    /// the main working tree, which the daemon never removes.
1790    removable: bool,
1791    /// Whether the target is the repository's main working tree.
1792    is_main: bool,
1793    /// Whether a live VS Code window currently has the target open.
1794    open: bool,
1795    /// The owning window's key, when `open` (the first, for the wait/close).
1796    #[serde(skip_serializing_if = "Option::is_none")]
1797    window_key: Option<String>,
1798    /// How many workspace folders the owning window has — so the extension can
1799    /// warn "this window has N folders open; all will close" (failure mode #10).
1800    window_folder_count: usize,
1801    /// Conditions that would lose data on removal; a non-empty list forces a
1802    /// confirm dialog.
1803    risks: Vec<Note>,
1804    /// Non-blocking context shown for awareness (e.g. unpushed commits that
1805    /// survive because the branch is kept).
1806    info: Vec<Note>,
1807}
1808
1809/// The git-only half of the safety check, before the registry's open-window
1810/// facts are folded in. Pure disk I/O; computed on a blocking thread.
1811#[derive(Debug, Clone, PartialEq, Eq)]
1812struct GitSafety {
1813    is_main: bool,
1814    removable: bool,
1815    risks: Vec<Note>,
1816    info: Vec<Note>,
1817}
1818
1819/// Live windows (key, workspace-folder count) that currently have `path` open,
1820/// matched by canonicalized path so a symlinked or `..`-laden report still
1821/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
1822fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
1823    let target = canonical(path);
1824    entries
1825        .iter()
1826        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
1827        .map(|e| (e.key.clone(), e.folders.len()))
1828        .collect()
1829}
1830
1831/// How long the execute phase waits for a signalled window to close
1832/// (`unregister`) before giving up. Deliberately generous against the ~10s
1833/// heartbeat interval the close directive rides — a window may have just
1834/// heartbeated, so the directive is only picked up on the *next* one — plus the
1835/// window's own close/save latency. The keyed-push responsiveness upgrade
1836/// (#1277 fast-follow) removes this wait entirely.
1837const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
1838
1839/// How often the execute phase re-checks whether the signalled windows have
1840/// unregistered.
1841const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
1842
1843/// Waits up to `timeout` for every window *other than* `requester` that has
1844/// `path` open to unregister (close), polling the live registry every `poll`.
1845/// A window whose `last_seen` has already gone stale is reaped by `list()` and
1846/// so counts as closed. Returns an error naming the still-open windows on
1847/// timeout, so the caller can surface "window did not close" and leave the
1848/// worktree untouched (failure modes #4/#5).
1849async fn await_windows_closed(
1850    registry: &WorktreesRegistry,
1851    path: &Path,
1852    requester: Option<&str>,
1853    timeout: Duration,
1854    poll: Duration,
1855) -> Result<()> {
1856    let deadline = std::time::Instant::now() + timeout;
1857    loop {
1858        // The registry read is cheap CPU, but the path canonicalization in
1859        // `windows_with_path` is disk I/O — do the whole check on a blocking
1860        // thread, never on the async worker.
1861        let entries = registry.list();
1862        let path = path.to_path_buf();
1863        let requester = requester.map(str::to_string);
1864        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
1865            windows_with_path(&entries, &path)
1866                .into_iter()
1867                .map(|(k, _)| k)
1868                .filter(|k| requester.as_deref() != Some(k))
1869                .collect()
1870        })
1871        .await
1872        .unwrap_or_default();
1873
1874        if remaining.is_empty() {
1875            return Ok(());
1876        }
1877        if std::time::Instant::now() >= deadline {
1878            bail!("window(s) did not close in time: {}", remaining.join(", "));
1879        }
1880        tokio::time::sleep(poll).await;
1881    }
1882}
1883
1884/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
1885/// working tree (never removable) and, for a linked worktree, what a removal
1886/// would lose. Best-effort per-check but the overall open must succeed — a path
1887/// that is not a git worktree is a hard error (we refuse to delete an unknown
1888/// directory). A path that no longer exists is treated as an already-removed
1889/// linked worktree so the idempotent execute path can proceed with no dialog.
1890fn git_safety(path: &Path) -> Result<GitSafety> {
1891    if !path.exists() {
1892        return Ok(GitSafety {
1893            is_main: false,
1894            removable: true,
1895            risks: vec![],
1896            info: vec![Note::new("already-removed", "worktree no longer exists")],
1897        });
1898    }
1899    let repo = Repository::open(path)
1900        .with_context(|| format!("not a git worktree: {}", path.display()))?;
1901    // The one structural fact deletability keys off — never the branch name.
1902    if !repo.is_worktree() {
1903        return Ok(GitSafety {
1904            is_main: true,
1905            removable: false,
1906            risks: vec![],
1907            info: vec![Note::new(
1908                "main-working-tree",
1909                "the repository's main working tree is never deleted",
1910            )],
1911        });
1912    }
1913
1914    let mut risks = Vec::new();
1915    let mut info = Vec::new();
1916
1917    let (dirty, untracked) = count_dirty_untracked(&repo);
1918    if dirty > 0 {
1919        risks.push(Note::new(
1920            "dirty",
1921            format!("{dirty} modified tracked file(s) would be lost"),
1922        ));
1923    }
1924    if untracked > 0 {
1925        risks.push(Note::new(
1926            "untracked",
1927            format!("{untracked} untracked file(s) would be lost"),
1928        ));
1929    }
1930
1931    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
1932    let state = repo.state();
1933    if state != RepositoryState::Clean {
1934        risks.push(Note::new(
1935            "in-progress",
1936            format!("an in-progress {state:?} operation would be lost"),
1937        ));
1938    }
1939
1940    // Commits reachable only from a detached HEAD are GC'd once the worktree —
1941    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
1942    // or tag) loses nothing, so it is not flagged.
1943    if repo.head_detached().unwrap_or(false) {
1944        let lost = unreachable_commit_count(&repo).unwrap_or(0);
1945        if lost > 0 {
1946            risks.push(Note::new(
1947                "unreachable-commits",
1948                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
1949            ));
1950        }
1951    }
1952
1953    // Unpushed commits on a *named* branch survive: removal never deletes the
1954    // branch. Informational only — it must not block or prompt.
1955    if let Some(ahead) = current_branch_ahead(&repo) {
1956        if ahead > 0 {
1957            info.push(Note::new(
1958                "unpushed",
1959                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
1960            ));
1961        }
1962    }
1963
1964    Ok(GitSafety {
1965        is_main: false,
1966        removable: true,
1967        risks,
1968        info,
1969    })
1970}
1971
1972/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
1973/// staged or unstaged modification (including conflicts and deletions);
1974/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
1975/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
1976/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
1977/// `(0, 0)` rather than sinking the whole safety check.
1978fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
1979    let mut opts = StatusOptions::new();
1980    opts.include_untracked(true)
1981        .recurse_untracked_dirs(true)
1982        .include_ignored(false)
1983        .exclude_submodules(true);
1984    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1985        return (0, 0);
1986    };
1987    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
1988    // it is deliberately excluded from this mask).
1989    let tracked = Status::INDEX_NEW
1990        | Status::INDEX_MODIFIED
1991        | Status::INDEX_DELETED
1992        | Status::INDEX_RENAMED
1993        | Status::INDEX_TYPECHANGE
1994        | Status::WT_MODIFIED
1995        | Status::WT_DELETED
1996        | Status::WT_TYPECHANGE
1997        | Status::WT_RENAMED
1998        | Status::CONFLICTED;
1999    let mut dirty = 0;
2000    let mut untracked = 0;
2001    for entry in statuses.iter() {
2002        let s = entry.status();
2003        if s.contains(Status::WT_NEW) {
2004            untracked += 1;
2005        }
2006        if s.intersects(tracked) {
2007            dirty += 1;
2008        }
2009    }
2010    (dirty, untracked)
2011}
2012
2013/// Counts commits reachable from the (detached) HEAD but from no other ref —
2014/// the commits git would garbage-collect once the worktree's HEAD is gone.
2015/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
2016/// skipped (hiding it would hide the very commits we are counting); every real
2017/// branch/tag/remote ref is hidden, so a tip that any branch also points at
2018/// yields `0` (nothing is actually lost).
2019fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
2020    let head_oid = repo.head().ok()?.target()?;
2021    let mut walk = repo.revwalk().ok()?;
2022    walk.push(head_oid).ok()?;
2023    for reference in repo.references().ok()? {
2024        let Ok(reference) = reference else { continue };
2025        // Skip the literal HEAD ref — hiding it would hide the very commits we
2026        // are counting; every real branch/tag/remote ref is hidden below.
2027        if matches!(reference.name(), Ok("HEAD")) {
2028            continue;
2029        }
2030        if let Some(oid) = reference.target() {
2031            let _ = walk.hide(oid);
2032        }
2033    }
2034    Some(walk.flatten().count())
2035}
2036
2037/// Commits the worktree's current branch is ahead of its upstream, or `None`
2038/// when HEAD is detached or the branch tracks no upstream. Reuses
2039/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
2040fn current_branch_ahead(repo: &Repository) -> Option<usize> {
2041    let head = repo.head().ok()?;
2042    if !head.is_branch() {
2043        return None;
2044    }
2045    let branch = git2::Branch::wrap(head);
2046    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
2047}
2048
2049/// Resolves the linked worktree whose working directory canonicalizes to
2050/// `target` to its registered name in `main_repo`. Errors when `target` is not
2051/// one of the repo's worktrees — the defensive guard against removing a path
2052/// that opened as a worktree but is not enumerated. Split out so that guard is
2053/// unit-testable without corrupting git's worktree admin state.
2054fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
2055    let names = main_repo.worktrees()?;
2056    names
2057        .iter()
2058        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
2059        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
2060        .find(|name| {
2061            main_repo
2062                .find_worktree(name)
2063                .is_ok_and(|wt| canonical(wt.path()) == target)
2064        })
2065        .map(str::to_string)
2066        .ok_or_else(|| {
2067            anyhow!(
2068                "worktree {} is not registered in {}",
2069                target.display(),
2070                main_repo.path().display()
2071            )
2072        })
2073}
2074
2075/// Backoff delays between recursive-removal retries (#1315). A concurrent
2076/// writer — a just-closed window's language server (Metals/Bloop) or
2077/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
2078/// create a file between our directory scan and its `rmdir`, making the removal
2079/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
2080/// waits longer, giving the winding-down process time to quiesce. Total wait
2081/// ~2.75s across four retries; the window teardown the caller already waited on
2082/// dominates it.
2083const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
2084    Duration::from_millis(250),
2085    Duration::from_millis(500),
2086    Duration::from_secs(1),
2087    Duration::from_secs(1),
2088];
2089
2090/// Whether `e` is the transient "directory re-populated under us" race we retry
2091/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
2092/// denied, read-only filesystem) we must surface immediately. Matches the raw
2093/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
2094/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
2095fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
2096    matches!(
2097        e.raw_os_error(),
2098        Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
2099    )
2100}
2101
2102/// Recursively removes `dir`, retrying on the transient concurrent-writer race
2103/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
2104/// as success. Non-transient errors surface immediately with the original
2105/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
2106/// `spawn_blocking`), so the between-retry `sleep` is fine.
2107fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
2108    remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
2109}
2110
2111/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
2112/// Provoking the real race requires a concurrent writer to lose a timing window,
2113/// so only an injected sequence of errors can drive every branch of the loop —
2114/// exhausting the backoff especially — deterministically and without sleeping out
2115/// the production schedule.
2116fn remove_dir_all_retrying_with(
2117    dir: &Path,
2118    backoff: &[Duration],
2119    mut remove: impl FnMut() -> std::io::Result<()>,
2120) -> Result<()> {
2121    let mut backoff = backoff.iter();
2122    loop {
2123        match remove() {
2124            Ok(()) => return Ok(()),
2125            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2126            Err(e) => {
2127                if is_transient_rmdir_error(&e) {
2128                    if let Some(delay) = backoff.next() {
2129                        std::thread::sleep(*delay);
2130                        continue;
2131                    }
2132                }
2133                return Err(e).with_context(|| {
2134                    format!("failed to remove worktree directory {}", dir.display())
2135                });
2136            }
2137        }
2138    }
2139}
2140
2141/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
2142/// still points at an admin directory a prior failed removal already deleted.
2143/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
2144/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
2145/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
2146/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
2147/// opens) and a normal checkout has a `.git` *directory*, so this matches
2148/// neither.
2149fn is_orphaned_worktree(path: &Path) -> bool {
2150    // `read_to_string` fails on a `.git` directory (a normal checkout), so only
2151    // a linked worktree's gitlink file gets past here.
2152    let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
2153        return false;
2154    };
2155    let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
2156        return false;
2157    };
2158    let admin = Path::new(admin);
2159    // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
2160    admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
2161}
2162
2163/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
2164/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
2165/// directory and the admin metadata. Refuses the main working tree (the
2166/// defensive backstop behind the UI gating) and a locked worktree (surfacing
2167/// "unlock first" rather than forcing past the lock). Idempotent: an
2168/// already-removed path is a success.
2169///
2170/// The working tree is removed **first** (retrying to absorb the
2171/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
2172/// This is deliberately the reverse of libgit2's combined
2173/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
2174/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
2175/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
2176/// is already gone). Doing the directory first means a transient failure leaves
2177/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
2178/// the old ordering is detected and its leftover directory cleaned up directly.
2179fn remove_worktree(path: &Path) -> Result<()> {
2180    if !path.exists() {
2181        return Ok(());
2182    }
2183    let repo = match Repository::open(path) {
2184        Ok(repo) => repo,
2185        // Admin metadata already gone (a prior failed removal); git no longer
2186        // tracks this path, so no prune applies — just delete the leftover.
2187        Err(_) if is_orphaned_worktree(path) => return remove_dir_all_retrying(path),
2188        Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
2189    };
2190    if !repo.is_worktree() {
2191        bail!(
2192            "refusing to delete the main working tree: {}",
2193            path.display()
2194        );
2195    }
2196    // The Worktree handle lives on the *main* repo (the common dir's parent),
2197    // keyed by name; find it by matching the target path.
2198    let commondir = canonical(repo.commondir());
2199    let main_root = commondir
2200        .parent()
2201        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
2202        .to_path_buf();
2203    // Drop the worktree-scoped handle before we delete its directory.
2204    drop(repo);
2205    let main_repo = Repository::open(&main_root)
2206        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
2207    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
2208    let worktree = main_repo.find_worktree(&name)?;
2209
2210    // Never silently force past a lock (failure mode #6).
2211    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
2212        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
2213        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
2214    }
2215
2216    // Delete the checked-out directory ourselves, retrying past the
2217    // concurrent-writer race (#1315).
2218    remove_dir_all_retrying(path)?;
2219
2220    // The directory is gone; prune only the admin metadata. working_tree(false)
2221    // keeps git2 from re-attempting (and failing on) the now-absent directory;
2222    // valid(true) prunes even though the worktree was valid; locked stays false,
2223    // so a lock (re-checked above) is never forced.
2224    let mut opts = git2::WorktreePruneOptions::new();
2225    opts.valid(true).working_tree(false);
2226    worktree
2227        .prune(Some(&mut opts))
2228        .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
2229    Ok(())
2230}
2231
2232#[cfg(test)]
2233#[allow(clippy::unwrap_used, clippy::expect_used)]
2234mod tests {
2235    use super::*;
2236    use crate::test_support::shim::{shim_lock, write_exec_script};
2237    use chrono::Utc;
2238    use std::sync::MutexGuard;
2239
2240    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
2241        json!({
2242            "key": key,
2243            "folders": [folder],
2244            "repo": repo,
2245            "title": format!("{key}-title"),
2246            "pid": 1234,
2247        })
2248    }
2249
2250    /// Pulls the `windows` array out of a `list`/`status` payload.
2251    fn windows_of(payload: &Value) -> &Vec<Value> {
2252        payload
2253            .get("windows")
2254            .and_then(Value::as_array)
2255            .expect("windows array")
2256    }
2257
2258    #[tokio::test]
2259    async fn name_and_unknown_op() {
2260        let svc = WorktreesService::new();
2261        assert_eq!(svc.name(), "worktrees");
2262        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
2263    }
2264
2265    #[tokio::test]
2266    async fn handle_routes_ops_and_shapes_payloads() {
2267        let svc = WorktreesService::new();
2268        // Empty to start.
2269        let payload = svc.handle("list", Value::Null).await.unwrap();
2270        assert_eq!(payload, json!({ "windows": [] }));
2271
2272        // register → { ok: true }, then it shows up in list.
2273        let reply = svc
2274            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
2275            .await
2276            .unwrap();
2277        assert_eq!(reply, json!({ "ok": true }));
2278        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
2279        assert_eq!(windows.len(), 1);
2280        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
2281        assert!(windows[0].get("last_seen").is_some());
2282
2283        // heartbeat known/unknown.
2284        let known = svc
2285            .handle("heartbeat", json!({ "key": "w1" }))
2286            .await
2287            .unwrap();
2288        assert_eq!(known, json!({ "known": true }));
2289        let unknown = svc
2290            .handle("heartbeat", json!({ "key": "nope" }))
2291            .await
2292            .unwrap();
2293        assert_eq!(unknown, json!({ "known": false }));
2294
2295        // unregister removes, then repeats as a no-op success.
2296        let gone = svc
2297            .handle("unregister", json!({ "key": "w1" }))
2298            .await
2299            .unwrap();
2300        assert_eq!(gone, json!({ "removed": true }));
2301        let again = svc
2302            .handle("unregister", json!({ "key": "w1" }))
2303            .await
2304            .unwrap();
2305        assert_eq!(again, json!({ "removed": false }));
2306    }
2307
2308    #[tokio::test]
2309    async fn handle_rejects_missing_or_empty_key() {
2310        let svc = WorktreesService::new();
2311        // register validates a present, non-blank key.
2312        assert!(svc.handle("register", json!({})).await.is_err());
2313        assert!(svc
2314            .handle("register", json!({ "key": "  " }))
2315            .await
2316            .is_err());
2317        // heartbeat/unregister require the key via `require_str`.
2318        assert!(svc.handle("heartbeat", json!({})).await.is_err());
2319        assert!(svc.handle("unregister", json!({})).await.is_err());
2320    }
2321
2322    #[test]
2323    fn display_name_prefers_repo_then_folder_basename() {
2324        let base = WindowEntry {
2325            key: "k".to_string(),
2326            folders: vec![PathBuf::from("/home/me/project")],
2327            repo: Some("my-repo".to_string()),
2328            title: None,
2329            pid: None,
2330            last_seen: Utc::now(),
2331        };
2332        assert_eq!(display_name(&base), "my-repo");
2333
2334        let no_repo = WindowEntry {
2335            repo: None,
2336            ..base.clone()
2337        };
2338        assert_eq!(display_name(&no_repo), "project");
2339
2340        let nothing = WindowEntry {
2341            repo: None,
2342            folders: vec![],
2343            ..base.clone()
2344        };
2345        assert_eq!(display_name(&nothing), "(no folder)");
2346
2347        // A folder with no basename (the filesystem root) falls back to its
2348        // displayed path rather than panicking or yielding an empty name.
2349        let rootish = WindowEntry {
2350            repo: None,
2351            folders: vec![PathBuf::from("/")],
2352            ..base
2353        };
2354        assert_eq!(display_name(&rootish), "/");
2355    }
2356
2357    #[test]
2358    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
2359        let now = Utc::now();
2360        let entries = vec![
2361            // A folderless window has nothing to focus, so it stays a plain
2362            // Label; a title equal to the name collapses to just the name. It
2363            // leads the list so the focus-action lookup below is exercised
2364            // against a leading non-Action item it has to skip.
2365            WindowEntry {
2366                key: "k2".to_string(),
2367                folders: vec![],
2368                repo: Some("solo".to_string()),
2369                title: Some("solo".to_string()),
2370                pid: None,
2371                last_seen: now,
2372            },
2373            // A folder-bearing, non-repo window: one clickable Action whose label
2374            // is the stats line ("name · title", since /tmp is not a git repo).
2375            WindowEntry {
2376                key: "k1".to_string(),
2377                folders: vec![PathBuf::from("/tmp/a")],
2378                repo: Some("repo".to_string()),
2379                title: Some("a branch".to_string()),
2380                pid: None,
2381                last_seen: now,
2382            },
2383        ];
2384        let items = window_menu_items(&entries);
2385        // Exactly one item per window — no duplicate label, no separator.
2386        assert_eq!(items.len(), 2);
2387        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
2388
2389        // The folder-bearing window is a single clickable action carrying the
2390        // stats label (the old label + Focus action, merged).
2391        let action = items
2392            .iter()
2393            .find_map(|i| match i {
2394                MenuItem::Action(a) => Some(a),
2395                _ => None,
2396            })
2397            .expect("a focus action");
2398        assert_eq!(action.id, "focus:k1");
2399        assert_eq!(action.label, "repo · a branch");
2400
2401        // The folderless window is a non-clickable label (not "solo · solo").
2402        let labels: Vec<&str> = items
2403            .iter()
2404            .filter_map(|i| match i {
2405                MenuItem::Label(t) => Some(t.as_str()),
2406                _ => None,
2407            })
2408            .collect();
2409        assert_eq!(labels, vec!["solo"]);
2410    }
2411
2412    #[tokio::test]
2413    async fn menu_and_status_shapes() {
2414        let svc = WorktreesService::new();
2415        // Empty.
2416        let menu = svc.menu();
2417        assert_eq!(menu.title, "Worktrees");
2418        assert!(matches!(
2419            menu.items.first(),
2420            Some(MenuItem::Label(text)) if text == "No open windows"
2421        ));
2422        let status = svc.status().await;
2423        assert_eq!(status.name, "worktrees");
2424        assert!(status.healthy);
2425        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
2426
2427        // Two folder-bearing windows in the same repo, plus one folderless
2428        // window that shares the repo but has nothing for `code` to open.
2429        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
2430            .await
2431            .unwrap();
2432        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
2433            .await
2434            .unwrap();
2435        svc.handle(
2436            "register",
2437            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
2438        )
2439        .await
2440        .unwrap();
2441        let status = svc.status().await;
2442        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
2443
2444        let menu = svc.menu();
2445        // One line per window — no separator, no duplicate label.
2446        assert_eq!(menu.items.len(), 3);
2447        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
2448        let action_ids: Vec<&str> = menu
2449            .items
2450            .iter()
2451            .filter_map(|i| match i {
2452                MenuItem::Action(a) => Some(a.id.as_str()),
2453                _ => None,
2454            })
2455            .collect();
2456        // The two folder-bearing windows are clickable; the folderless one is a
2457        // plain Label, so it never yields a focus action.
2458        assert!(action_ids.contains(&"focus:w1"));
2459        assert!(action_ids.contains(&"focus:w2"));
2460        assert!(!action_ids.contains(&"focus:w3"));
2461    }
2462
2463    #[test]
2464    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
2465        // With no tokio runtime, the background task is never spawned, so the
2466        // bare service keeps computing `menu()` inline (what the tests rely on).
2467        let svc = WorktreesService::new();
2468        svc.start_menu_refresh();
2469        assert!(svc.refresh.lock().unwrap().is_none());
2470    }
2471
2472    #[tokio::test]
2473    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
2474        let svc = WorktreesService::new();
2475        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
2476            .await
2477            .unwrap();
2478        // Before the task runs, `menu()` computes inline from an empty cache.
2479        assert!(svc.menu_cache.lock().unwrap().is_none());
2480
2481        svc.start_menu_refresh();
2482        // Idempotent: a second call does not start a second task.
2483        svc.start_menu_refresh();
2484
2485        // The task fills the cache off the main thread; poll briefly for it.
2486        let mut filled = false;
2487        for _ in 0..100 {
2488            if svc.menu_cache.lock().unwrap().is_some() {
2489                filled = true;
2490                break;
2491            }
2492            tokio::time::sleep(Duration::from_millis(10)).await;
2493        }
2494        assert!(filled, "background refresh should populate the menu cache");
2495
2496        // `menu()` now serves the cache: one clickable line for the window.
2497        let menu = svc.menu();
2498        assert_eq!(menu.title, "Worktrees");
2499        assert!(menu
2500            .items
2501            .iter()
2502            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
2503
2504        // Shutdown cancels and joins the task, clearing the handle.
2505        svc.shutdown().await;
2506        assert!(svc.refresh.lock().unwrap().is_none());
2507    }
2508
2509    #[tokio::test]
2510    async fn default_constructs_an_empty_service() {
2511        let svc = WorktreesService::default();
2512        let payload = svc.handle("list", Value::Null).await.unwrap();
2513        assert_eq!(payload, json!({ "windows": [] }));
2514    }
2515
2516    // --- Push subscription (#1267) -----------------------------------------
2517
2518    #[tokio::test]
2519    async fn subscribe_streams_only_for_the_subscribe_op() {
2520        let svc = WorktreesService::new();
2521        // The one streaming op yields a stream; every other op (including the
2522        // request/reply worktrees ops) declines, so the server dispatches them
2523        // normally.
2524        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
2525        assert!(svc.subscribe("list", &Value::Null).is_none());
2526        assert!(svc.subscribe("register", &Value::Null).is_none());
2527        assert!(svc.subscribe("bogus", &Value::Null).is_none());
2528    }
2529
2530    #[tokio::test]
2531    async fn subscribe_snapshot_matches_the_tree_op() {
2532        let dir = tempfile::tempdir().unwrap();
2533        let repo = init_repo(dir.path());
2534        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2535        repo.set_head("refs/heads/main").unwrap();
2536
2537        let svc = WorktreesService::new();
2538        let stream = svc
2539            .subscribe("subscribe", &Value::Null)
2540            .expect("subscribe stream");
2541        // No windows yet → no repos derived; the toggle rides along at its
2542        // default (show all).
2543        assert_eq!(
2544            stream.snapshot().await,
2545            json!({ "repos": [], "show_closed": true })
2546        );
2547
2548        // A window opens on the repo → the snapshot carries it, byte-identical to
2549        // what the `tree` op returns for the same registry state.
2550        svc.handle(
2551            "register",
2552            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2553        )
2554        .await
2555        .unwrap();
2556        let snap = stream.snapshot().await;
2557        let tree = svc.handle("tree", Value::Null).await.unwrap();
2558        assert_eq!(snap, tree);
2559        let repos = snap["repos"].as_array().expect("repos array");
2560        assert_eq!(repos.len(), 1);
2561        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
2562    }
2563
2564    #[tokio::test]
2565    async fn subscribe_changed_wakes_on_register() {
2566        let svc = WorktreesService::new();
2567        let mut stream = svc
2568            .subscribe("subscribe", &Value::Null)
2569            .expect("subscribe stream");
2570        // Idle: `changed()` must not resolve without a registry change.
2571        tokio::select! {
2572            () = stream.changed() => panic!("changed resolved with no registry change"),
2573            () = tokio::time::sleep(Duration::from_millis(50)) => {}
2574        }
2575        // A register bumps the change-notify → `changed()` resolves promptly.
2576        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
2577            .await
2578            .unwrap();
2579        tokio::time::timeout(Duration::from_secs(1), stream.changed())
2580            .await
2581            .expect("changed should resolve after a register");
2582    }
2583
2584    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
2585
2586    #[tokio::test]
2587    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
2588        let reg = Arc::new(WorktreesRegistry::new());
2589        // A long TTL so only the generation gate is exercised here.
2590        let cache = TreeSnapshotCache::with_ttl(
2591            reg,
2592            Arc::new(PrStatusCache::new()),
2593            Duration::from_secs(60),
2594        );
2595        // The first read builds once.
2596        let first = cache.snapshot().await;
2597        assert_eq!(cache.compute_count(), 1);
2598        // Further reads with no registry change and within the TTL reuse the
2599        // cached value — no extra build, byte-identical result.
2600        let second = cache.snapshot().await;
2601        assert_eq!(
2602            cache.compute_count(),
2603            1,
2604            "an unchanged read must not rebuild"
2605        );
2606        assert_eq!(first, second);
2607    }
2608
2609    #[tokio::test]
2610    async fn tree_cache_single_flights_a_read_burst() {
2611        let reg = Arc::new(WorktreesRegistry::new());
2612        let cache = Arc::new(TreeSnapshotCache::with_ttl(
2613            reg,
2614            Arc::new(PrStatusCache::new()),
2615            Duration::from_secs(60),
2616        ));
2617        // A burst of concurrent readers — as N subscriber streams would wake
2618        // together on a change/tick — collapses to exactly one build; the rest
2619        // read the shared result (the acceptance criterion).
2620        let mut handles = Vec::new();
2621        for _ in 0..16 {
2622            let cache = cache.clone();
2623            handles.push(tokio::spawn(async move { cache.snapshot().await }));
2624        }
2625        let mut results = Vec::new();
2626        for handle in handles {
2627            results.push(handle.await.unwrap());
2628        }
2629        assert_eq!(
2630            cache.compute_count(),
2631            1,
2632            "a concurrent read burst must build the tree once"
2633        );
2634        assert!(
2635            results.windows(2).all(|w| w[0] == w[1]),
2636            "every reader must observe the identical snapshot"
2637        );
2638    }
2639
2640    #[tokio::test]
2641    async fn tree_cache_rebuilds_on_registry_change() {
2642        let reg = Arc::new(WorktreesRegistry::new());
2643        let cache = TreeSnapshotCache::with_ttl(
2644            reg.clone(),
2645            Arc::new(PrStatusCache::new()),
2646            Duration::from_secs(60),
2647        );
2648        cache.snapshot().await;
2649        assert_eq!(cache.compute_count(), 1);
2650        // A registry change bumps the generation, so the next read rebuilds even
2651        // though the (long) TTL has not expired — subscribers never see a stale
2652        // visible set.
2653        assert!(reg.set_show_closed(false));
2654        cache.snapshot().await;
2655        assert_eq!(
2656            cache.compute_count(),
2657            2,
2658            "a generation bump must force a rebuild"
2659        );
2660    }
2661
2662    #[tokio::test]
2663    async fn tree_cache_rebuilds_after_ttl_expiry() {
2664        let reg = Arc::new(WorktreesRegistry::new());
2665        // A zero TTL: every read is already past it, so a pure on-disk git change
2666        // still surfaces on the next tick with no registry bump needed.
2667        let cache =
2668            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
2669        cache.snapshot().await;
2670        cache.snapshot().await;
2671        assert_eq!(
2672            cache.compute_count(),
2673            2,
2674            "an expired TTL must force a rebuild each read"
2675        );
2676    }
2677
2678    #[tokio::test]
2679    async fn subscribe_streams_share_one_build_per_generation() {
2680        let svc = WorktreesService::new();
2681        let s1 = svc
2682            .subscribe("subscribe", &Value::Null)
2683            .expect("subscribe stream");
2684        let s2 = svc
2685            .subscribe("subscribe", &Value::Null)
2686            .expect("subscribe stream");
2687        // Two windows' streams sampling the same registry state build the tree
2688        // once, not once per stream (#1303) — they share the service's cache.
2689        let a = s1.snapshot().await;
2690        let b = s2.snapshot().await;
2691        assert_eq!(a, b);
2692        assert_eq!(
2693            svc.tree_cache.compute_count(),
2694            1,
2695            "N streams on one generation must share a single build"
2696        );
2697    }
2698
2699    // --- Show/hide-closed toggle (#1301) -----------------------------------
2700
2701    #[tokio::test]
2702    async fn set_show_closed_toggles_the_snapshot_field() {
2703        let svc = WorktreesService::new();
2704        // The snapshot carries the toggle; it defaults to show-all.
2705        assert_eq!(
2706            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2707            json!(true)
2708        );
2709        // Setting it flips the field the next snapshot reports.
2710        let reply = svc
2711            .handle("set-show-closed", json!({ "show_closed": false }))
2712            .await
2713            .unwrap();
2714        assert_eq!(reply, json!({ "ok": true }));
2715        assert_eq!(
2716            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
2717            json!(false)
2718        );
2719    }
2720
2721    #[tokio::test]
2722    async fn set_show_closed_rejects_a_non_boolean_payload() {
2723        let svc = WorktreesService::new();
2724        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
2725        assert!(svc
2726            .handle("set-show-closed", json!({ "show_closed": "yes" }))
2727            .await
2728            .is_err());
2729    }
2730
2731    #[tokio::test]
2732    async fn set_show_closed_wakes_the_subscription() {
2733        let svc = WorktreesService::new();
2734        let mut stream = svc
2735            .subscribe("subscribe", &Value::Null)
2736            .expect("subscribe stream");
2737        // A real flip bumps the change-notify → `changed()` resolves promptly.
2738        svc.handle("set-show-closed", json!({ "show_closed": false }))
2739            .await
2740            .unwrap();
2741        tokio::time::timeout(Duration::from_secs(1), stream.changed())
2742            .await
2743            .expect("changed should resolve after a toggle flip");
2744        // The pushed snapshot now reflects the new toggle.
2745        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
2746    }
2747
2748    #[tokio::test]
2749    async fn menu_action_rejects_unknown_and_missing_window() {
2750        let svc = WorktreesService::new();
2751        assert!(svc.menu_action("bogus").await.is_err());
2752        // A focus for a key with no registration errors rather than spawning.
2753        assert!(svc.menu_action("focus:nope").await.is_err());
2754        svc.shutdown().await;
2755    }
2756
2757    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
2758    /// variable (via `resolve_code_binary` → `focus_window`) —
2759    /// `menu_action_focus_resolves_folder_and_spawns` and
2760    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
2761    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
2762    /// transient overlap under the harness's test parallelism is benign.
2763    struct VscodeBinGuard(Option<std::ffi::OsString>);
2764    impl Drop for VscodeBinGuard {
2765        fn drop(&mut self) {
2766            match self.0.take() {
2767                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
2768                None => std::env::remove_var(VSCODE_BIN_ENV),
2769            }
2770        }
2771    }
2772
2773    #[tokio::test]
2774    async fn menu_action_focus_resolves_folder_and_spawns() {
2775        let dir = tempfile::tempdir().unwrap();
2776        let svc = WorktreesService::new();
2777        svc.handle(
2778            "register",
2779            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
2780        )
2781        .await
2782        .unwrap();
2783
2784        // Point the launcher at a harmless binary so the spawn deterministically
2785        // succeeds and the focus path returns Ok.
2786        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2787        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2788        svc.menu_action("focus:w1").await.unwrap();
2789    }
2790
2791    #[tokio::test]
2792    async fn open_rejects_missing_relative_or_nonexistent_path() {
2793        let svc = WorktreesService::new();
2794        // A missing `path` is a payload error.
2795        assert!(svc.handle("open", json!({})).await.is_err());
2796        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
2797        // A relative path is rejected before any spawn — this is also what
2798        // blocks a `-`-leading argument from reaching `code` as a flag.
2799        assert!(svc
2800            .handle("open", json!({ "path": "relative/dir" }))
2801            .await
2802            .is_err());
2803        assert!(svc
2804            .handle("open", json!({ "path": "-flag" }))
2805            .await
2806            .is_err());
2807        // An absolute path that does not exist is rejected before any spawn, so
2808        // no launcher is needed for these guard cases.
2809        assert!(svc
2810            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
2811            .await
2812            .is_err());
2813        svc.shutdown().await;
2814    }
2815
2816    #[tokio::test]
2817    async fn open_focuses_an_existing_absolute_dir() {
2818        let dir = tempfile::tempdir().unwrap();
2819        let svc = WorktreesService::new();
2820        // Pin the launcher to a harmless binary so the spawn deterministically
2821        // succeeds whether or not `code` is installed. Unlike the tray `focus`
2822        // path, `open` takes the folder straight from the payload — no prior
2823        // `register` is required.
2824        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
2825        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
2826        let reply = svc
2827            .handle("open", json!({ "path": dir.path() }))
2828            .await
2829            .unwrap();
2830        assert_eq!(reply, json!({ "ok": true }));
2831        svc.shutdown().await;
2832    }
2833
2834    #[test]
2835    fn focus_window_with_validates_folder_then_spawns() {
2836        let dir = tempfile::tempdir().unwrap();
2837        // Non-absolute and missing-directory folders are rejected before spawn.
2838        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
2839        assert!(
2840            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
2841        );
2842        // A valid absolute directory spawns the launcher successfully.
2843        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
2844        // A missing launcher surfaces the spawn error (with context), not Ok.
2845        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
2846    }
2847
2848    #[test]
2849    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
2850        // Env override wins outright.
2851        assert_eq!(
2852            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
2853            PathBuf::from("/custom/code")
2854        );
2855        // No override: the first existing candidate is chosen.
2856        let existing = tempfile::NamedTempFile::new().unwrap();
2857        let existing_path = existing.path().to_str().unwrap();
2858        assert_eq!(
2859            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
2860            PathBuf::from(existing_path)
2861        );
2862        // Nothing exists: fall back to bare `code` on PATH.
2863        assert_eq!(
2864            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
2865            PathBuf::from("code")
2866        );
2867        // The real-env wrapper resolves without panicking.
2868        let _ = resolve_code_binary();
2869    }
2870
2871    // --- Git enrichment (#1186) --------------------------------------------
2872
2873    /// Initializes a fresh repo with a deterministic identity so `commit()`
2874    /// works without depending on a global git config.
2875    fn init_repo(dir: &Path) -> Repository {
2876        let repo = Repository::init(dir).unwrap();
2877        let mut cfg = repo.config().unwrap();
2878        cfg.set_str("user.name", "Test").unwrap();
2879        cfg.set_str("user.email", "test@example.com").unwrap();
2880        repo
2881    }
2882
2883    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
2884    /// optionally moving `refname` to it, and returns its oid.
2885    fn empty_commit(
2886        repo: &Repository,
2887        refname: Option<&str>,
2888        parents: &[&git2::Commit<'_>],
2889        msg: &str,
2890    ) -> git2::Oid {
2891        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2892        let tree = repo
2893            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
2894            .unwrap();
2895        repo.commit(refname, &sig, &sig, msg, &tree, parents)
2896            .unwrap()
2897    }
2898
2899    /// Commits `content` as file `name` onto `refname`, chaining off its current
2900    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
2901    /// the file is checked out into a worktree and can then be modified to
2902    /// produce a dirty (tracked) status.
2903    fn commit_file(
2904        repo: &Repository,
2905        refname: &str,
2906        name: &str,
2907        content: &[u8],
2908        msg: &str,
2909    ) -> git2::Oid {
2910        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2911        let blob = repo.blob(content).unwrap();
2912        let mut builder = repo.treebuilder(None).unwrap();
2913        builder.insert(name, blob, 0o100_644).unwrap();
2914        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
2915        let parent = repo
2916            .refname_to_id(refname)
2917            .ok()
2918            .and_then(|oid| repo.find_commit(oid).ok());
2919        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
2920        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
2921            .unwrap()
2922    }
2923
2924    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
2925    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
2926    fn diverging_repo(dir: &Path) -> Repository {
2927        let repo = init_repo(dir);
2928        // A: the shared base on `main`.
2929        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2930        let a_commit = repo.find_commit(a).unwrap();
2931        // origin/main diverges to C, a sibling of the local tip.
2932        let c = empty_commit(&repo, None, &[&a_commit], "C");
2933        repo.reference("refs/remotes/origin/main", c, true, "origin main")
2934            .unwrap();
2935        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
2936        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
2937        // Release the commit's borrow of `repo` so it can be returned.
2938        drop(a_commit);
2939        repo.set_head("refs/heads/main").unwrap();
2940        // Configure the tracking relationship so `upstream()` resolves.
2941        let mut cfg = repo.config().unwrap();
2942        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
2943            .unwrap();
2944        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
2945            .unwrap();
2946        cfg.set_str("branch.main.remote", "origin").unwrap();
2947        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
2948        repo
2949    }
2950
2951    #[test]
2952    fn git_status_reads_branch_and_ahead_behind() {
2953        let dir = tempfile::tempdir().unwrap();
2954        let _repo = diverging_repo(dir.path());
2955        let status = git_status(dir.path());
2956        assert_eq!(status.branch.as_deref(), Some("main"));
2957        assert_eq!(status.ahead, Some(1));
2958        assert_eq!(status.behind, Some(1));
2959        // A normal checkout names itself and is not flagged a worktree.
2960        assert_eq!(
2961            status.main_repo.as_deref(),
2962            dir.path().file_name().and_then(|n| n.to_str())
2963        );
2964        assert!(!status.is_worktree);
2965    }
2966
2967    #[test]
2968    fn git_status_empty_repo_is_unborn() {
2969        // A repo with no commits has an unborn HEAD, so `head()` errors and the
2970        // branch/sync fields stay empty rather than panicking — but the repo
2971        // identity is still resolved from the common dir.
2972        let dir = tempfile::tempdir().unwrap();
2973        init_repo(dir.path());
2974        let status = git_status(dir.path());
2975        assert_eq!(status.branch, None);
2976        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
2977        assert_eq!(status.head_sha, None);
2978        assert_eq!(status.ahead, None);
2979        assert_eq!(status.behind, None);
2980        assert_eq!(
2981            status.main_repo.as_deref(),
2982            dir.path().file_name().and_then(|n| n.to_str())
2983        );
2984        assert!(!status.is_worktree);
2985    }
2986
2987    #[test]
2988    fn git_status_no_upstream_reports_branch_only() {
2989        let dir = tempfile::tempdir().unwrap();
2990        let repo = init_repo(dir.path());
2991        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
2992        repo.set_head("refs/heads/main").unwrap();
2993        let status = git_status(dir.path());
2994        assert_eq!(status.branch.as_deref(), Some("main"));
2995        // No upstream → ahead/behind stay absent rather than zero.
2996        assert_eq!(status.ahead, None);
2997        assert_eq!(status.behind, None);
2998        // …and so does the upstream SHA, so such a branch still renders with no
2999        // sync indicator at all (#1344).
3000        assert_eq!(status.upstream_sha, None);
3001    }
3002
3003    #[test]
3004    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
3005        // A plain directory that is not a git repo yields nothing at all.
3006        let plain = tempfile::tempdir().unwrap();
3007        assert_eq!(git_status(plain.path()), GitStatus::default());
3008
3009        // A detached HEAD reports no branch (and thus no sync), but the repo
3010        // identity is still resolved from the common dir.
3011        let dir = tempfile::tempdir().unwrap();
3012        let repo = init_repo(dir.path());
3013        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3014        repo.set_head_detached(a).unwrap();
3015        let status = git_status(dir.path());
3016        assert_eq!(status.branch, None);
3017        // A detached HEAD has no branch but *does* have a commit — the SHA is
3018        // resolved before the branch filter, so it survives here (#1337).
3019        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
3020        assert_eq!(status.ahead, None);
3021        assert_eq!(status.behind, None);
3022        // A detached HEAD has no branch, so there is no upstream to resolve
3023        // either — the branch filter returns before the wrap (#1344).
3024        assert_eq!(status.upstream_sha, None);
3025        assert_eq!(
3026            status.main_repo.as_deref(),
3027            dir.path().file_name().and_then(|n| n.to_str())
3028        );
3029        assert!(!status.is_worktree);
3030    }
3031
3032    // --- Lazy ahead/behind (#1306) -----------------------------------------
3033
3034    #[test]
3035    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
3036        // The same repo `git_status` reports 1/1 for. The cheap variant used by
3037        // the streamed tree snapshot still reads the branch and repo identity, but
3038        // leaves ahead/behind absent — divergence is now lazy (#1306).
3039        let dir = tempfile::tempdir().unwrap();
3040        let repo = diverging_repo(dir.path());
3041        let status = git_status_cheap(dir.path());
3042        assert_eq!(status.branch.as_deref(), Some("main"));
3043        assert_eq!(status.ahead, None);
3044        assert_eq!(status.behind, None);
3045        assert_eq!(
3046            status.main_repo.as_deref(),
3047            dir.path().file_name().and_then(|n| n.to_str())
3048        );
3049        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
3050        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
3051        let head = repo.head().unwrap().target().unwrap();
3052        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
3053    }
3054
3055    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
3056
3057    #[test]
3058    fn git_status_head_sha_tracks_new_commits() {
3059        // The regression #1337 turns on: a commit must change the status the
3060        // snapshot is built from. Before the SHA rode the payload, committing
3061        // changed nothing on the wire, the server's diff dropped the identical
3062        // snapshot, and no client re-rendered — so a badge computed for the old
3063        // head survived the push that invalidated it.
3064        let dir = tempfile::tempdir().unwrap();
3065        let repo = init_repo(dir.path());
3066        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3067        repo.set_head("refs/heads/main").unwrap();
3068        let before = git_status_cheap(dir.path());
3069        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
3070
3071        let head = repo.find_commit(a).unwrap();
3072        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
3073        let after = git_status_cheap(dir.path());
3074        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
3075        assert_ne!(before.head_sha, after.head_sha);
3076        // The branch is unchanged — the SHA is the *only* thing that moved, which
3077        // is exactly why its absence made the push invisible.
3078        assert_eq!(before.branch, after.branch);
3079    }
3080
3081    // --- Upstream SHA on the snapshot (#1344) ------------------------------
3082
3083    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
3084    /// does, and all of what it does: the local branch and HEAD do not move. Lets
3085    /// these tests exercise a push with no network and no second repo.
3086    fn simulate_push(repo: &Repository, oid: git2::Oid) {
3087        repo.reference("refs/remotes/origin/main", oid, true, "push")
3088            .unwrap();
3089    }
3090
3091    #[test]
3092    fn git_status_upstream_sha_tracks_a_push() {
3093        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
3094        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
3095        // remote-tracking ref, so before this field rode the payload every wire
3096        // field was byte-identical across the push, the server's diff dropped the
3097        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
3098        // never re-asked — the row showed `↑1 ↓0` forever.
3099        let dir = tempfile::tempdir().unwrap();
3100        let repo = diverging_repo(dir.path());
3101        let before = git_status(dir.path());
3102        assert_eq!(before.ahead, Some(1));
3103        assert_eq!(before.behind, Some(1));
3104
3105        let head = repo.head().unwrap().target().unwrap();
3106        simulate_push(&repo, head);
3107        let after = git_status(dir.path());
3108
3109        // The upstream now names the pushed commit, and the counts agree.
3110        assert_eq!(
3111            after.upstream_sha.as_deref(),
3112            Some(head.to_string().as_str())
3113        );
3114        assert_ne!(before.upstream_sha, after.upstream_sha);
3115        assert_eq!(after.ahead, Some(0));
3116        assert_eq!(after.behind, Some(0));
3117        // Nothing else moved — which is the whole point. A push leaves the branch
3118        // and the local head exactly where they were, so `upstream_sha` is the
3119        // only signal a client could possibly notice.
3120        assert_eq!(before.branch, after.branch);
3121        assert_eq!(before.head_sha, after.head_sha);
3122    }
3123
3124    #[test]
3125    fn git_status_cheap_reports_upstream_sha() {
3126        // The crux: the field has to ride the *cheap* path, since that is the one
3127        // the streamed snapshot is built from. Costing a config lookup and a refs
3128        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
3129        // walk still absent here.
3130        let dir = tempfile::tempdir().unwrap();
3131        let repo = diverging_repo(dir.path());
3132        let status = git_status_cheap(dir.path());
3133        let upstream = repo
3134            .find_branch("origin/main", git2::BranchType::Remote)
3135            .unwrap()
3136            .get()
3137            .target()
3138            .unwrap();
3139        assert_eq!(
3140            status.upstream_sha.as_deref(),
3141            Some(upstream.to_string().as_str())
3142        );
3143        assert_eq!(status.ahead, None);
3144        assert_eq!(status.behind, None);
3145    }
3146
3147    #[test]
3148    fn folder_ahead_behind_computes_divergence_and_degrades() {
3149        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
3150        let dir = tempfile::tempdir().unwrap();
3151        let _repo = diverging_repo(dir.path());
3152        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
3153
3154        // A branch with no upstream → None (the tree renders no sync indicator).
3155        let no_up = tempfile::tempdir().unwrap();
3156        let repo = init_repo(no_up.path());
3157        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3158        repo.set_head("refs/heads/main").unwrap();
3159        assert_eq!(folder_ahead_behind(no_up.path()), None);
3160
3161        // A detached HEAD and a plain (non-repo) directory → None.
3162        let detached = tempfile::tempdir().unwrap();
3163        let drepo = init_repo(detached.path());
3164        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
3165        drepo.set_head_detached(a).unwrap();
3166        assert_eq!(folder_ahead_behind(detached.path()), None);
3167        let plain = tempfile::tempdir().unwrap();
3168        assert_eq!(folder_ahead_behind(plain.path()), None);
3169    }
3170
3171    #[tokio::test]
3172    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
3173        let diverging = tempfile::tempdir().unwrap();
3174        let _d = diverging_repo(diverging.path());
3175        let no_up = tempfile::tempdir().unwrap();
3176        let repo = init_repo(no_up.path());
3177        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3178        repo.set_head("refs/heads/main").unwrap();
3179
3180        let svc = WorktreesService::new();
3181        let diverging_path = diverging.path().display().to_string();
3182        let no_up_path = no_up.path().display().to_string();
3183        let reply = svc
3184            .handle(
3185                "ahead-behind",
3186                json!({ "paths": [&diverging_path, &no_up_path] }),
3187            )
3188            .await
3189            .unwrap();
3190        let results = reply.get("results").unwrap();
3191        // The diverging worktree carries its counts, keyed by the requested path.
3192        let d = results.get(diverging_path.as_str()).unwrap();
3193        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
3194        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
3195        // The no-upstream worktree is omitted entirely, not reported as zero.
3196        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
3197
3198        // A missing/empty `paths` list yields an empty results object, not an error.
3199        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
3200        assert_eq!(empty.get("results"), Some(&json!({})));
3201    }
3202
3203    #[tokio::test]
3204    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
3205        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
3206        let dir = tempfile::tempdir().unwrap();
3207        let _repo = diverging_repo(dir.path());
3208        let svc = WorktreesService::new();
3209        svc.handle(
3210            "register",
3211            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3212        )
3213        .await
3214        .unwrap();
3215
3216        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
3217        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
3218        let main_wt = &worktrees[0];
3219        // The cheap parts are present, but divergence is not — it is fetched
3220        // lazily via the `ahead-behind` op (#1306).
3221        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
3222        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
3223        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
3224    }
3225
3226    #[tokio::test]
3227    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
3228        // The end-to-end shape of the #1337 freshness fix. The server pushes a
3229        // snapshot only when it differs from the last one
3230        // (`server.rs`: `if snap != last`), so anything invisible on the wire
3231        // cannot trigger a re-render. Committing must therefore move the payload.
3232        let dir = tempfile::tempdir().unwrap();
3233        let repo = init_repo(dir.path());
3234        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3235        repo.set_head("refs/heads/main").unwrap();
3236
3237        let svc = WorktreesService::new();
3238        svc.handle(
3239            "register",
3240            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3241        )
3242        .await
3243        .unwrap();
3244
3245        let before = svc.handle("tree", Value::Null).await.unwrap();
3246        let wt = &repos_of(&before)[0]["worktrees"][0];
3247        assert_eq!(
3248            wt.get("head_sha").and_then(Value::as_str),
3249            Some(a.to_string().as_str())
3250        );
3251
3252        // Commit again: same branch, same paths, same open windows — pre-#1337 the
3253        // snapshot was byte-identical here and the push was dropped.
3254        let head = repo.find_commit(a).unwrap();
3255        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
3256        let after = svc.handle("tree", Value::Null).await.unwrap();
3257        assert_eq!(
3258            repos_of(&after)[0]["worktrees"][0]
3259                .get("head_sha")
3260                .and_then(Value::as_str),
3261            Some(b.to_string().as_str())
3262        );
3263        assert_ne!(before, after, "a commit must be a visible snapshot delta");
3264    }
3265
3266    #[tokio::test]
3267    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
3268        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
3269        // matching the payload's `skip_serializing_if` convention.
3270        let dir = tempfile::tempdir().unwrap();
3271        init_repo(dir.path());
3272        let svc = WorktreesService::new();
3273        svc.handle(
3274            "register",
3275            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3276        )
3277        .await
3278        .unwrap();
3279        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3280        assert!(wt.get("head_sha").is_none(), "{wt:?}");
3281    }
3282
3283    // --- Upstream SHA on the snapshot (#1344) ------------------------------
3284
3285    #[tokio::test]
3286    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
3287        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
3288        // moves neither the branch nor the local head, so `upstream_sha` is the
3289        // only field that can carry the news. Without it the snapshot serialised
3290        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
3291        // window re-rendered, and the lazy ahead/behind was never re-fetched.
3292        let dir = tempfile::tempdir().unwrap();
3293        let repo = diverging_repo(dir.path());
3294        let svc = WorktreesService::new();
3295        svc.handle(
3296            "register",
3297            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3298        )
3299        .await
3300        .unwrap();
3301
3302        let before = svc.handle("tree", Value::Null).await.unwrap();
3303        let head = repo.head().unwrap().target().unwrap();
3304        assert_ne!(
3305            repos_of(&before)[0]["worktrees"][0]
3306                .get("upstream_sha")
3307                .and_then(Value::as_str),
3308            Some(head.to_string().as_str()),
3309            "the fixture must start un-pushed for this to prove anything"
3310        );
3311
3312        // Push: only `refs/remotes/origin/main` moves.
3313        simulate_push(&repo, head);
3314        let after = svc.handle("tree", Value::Null).await.unwrap();
3315        let wt = &repos_of(&after)[0]["worktrees"][0];
3316        assert_eq!(
3317            wt.get("upstream_sha").and_then(Value::as_str),
3318            Some(head.to_string().as_str())
3319        );
3320        // The head and branch are untouched across the push — so this delta rests
3321        // entirely on `upstream_sha`.
3322        assert_eq!(
3323            wt.get("head_sha").and_then(Value::as_str),
3324            repos_of(&before)[0]["worktrees"][0]
3325                .get("head_sha")
3326                .and_then(Value::as_str)
3327        );
3328        assert_ne!(before, after, "a push must be a visible snapshot delta");
3329    }
3330
3331    #[tokio::test]
3332    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
3333        // Wire-compat, and the no-regression case: a branch tracking nothing sends
3334        // no key at all rather than a null, so an older client sees exactly the
3335        // payload it saw before and still renders no sync indicator.
3336        let dir = tempfile::tempdir().unwrap();
3337        let repo = init_repo(dir.path());
3338        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3339        repo.set_head("refs/heads/main").unwrap();
3340        let svc = WorktreesService::new();
3341        svc.handle(
3342            "register",
3343            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
3344        )
3345        .await
3346        .unwrap();
3347        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3348        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
3349        // The head still rides, so this is specifically the upstream degrading.
3350        assert!(wt.get("head_sha").is_some(), "{wt:?}");
3351    }
3352
3353    // --- PR badge poller (#1337) -------------------------------------------
3354
3355    /// Writes an executable stub that ignores its arguments and prints `stdout`,
3356    /// standing in for `gh api graphql` so the poll loop is exercised offline.
3357    /// Returns the shim lock alongside the path: the caller **must** hold the
3358    /// guard until the poller has finished exec'ing the stub. Writing an
3359    /// executable and then `execve`ing it races every other thread that forks —
3360    /// the child inherits the still-open writable FD and the exec fails
3361    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
3362    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
3363        let guard = shim_lock();
3364        let path = dir.join("fake-gh");
3365        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
3366        (path, guard)
3367    }
3368
3369    /// A repo with a GitHub origin and one commit on `main`.
3370    fn github_repo(dir: &Path) -> Repository {
3371        let repo = init_repo(dir);
3372        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3373        repo.set_head("refs/heads/main").unwrap();
3374        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
3375            .unwrap();
3376        repo
3377    }
3378
3379    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
3380    /// because the fold downgrades a badge naming a different commit than the
3381    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
3382    /// wrong reason.
3383    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
3384        PrBadge {
3385            number,
3386            is_draft: false,
3387            checks: PrCheckState::Pending,
3388            url: "u".into(),
3389            head_oid: head_oid.to_string(),
3390        }
3391    }
3392
3393    #[test]
3394    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
3395        let snapshot = json!({"repos":[
3396            {
3397                "main_repo":"omni-dev",
3398                "github":{"owner":"rust-works","name":"omni-dev"},
3399                "root":"/r",
3400                // Two worktrees on the same branch must ask once, not twice.
3401                "worktrees":[
3402                    {"path":"/r","branch":"main","is_main":true,"open":true},
3403                    {"path":"/w1","branch":"main","is_main":false,"open":true},
3404                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
3405                    // Detached: no branch, so nothing to resolve.
3406                    {"path":"/w3","is_main":false,"open":true}
3407                ]
3408            },
3409            {
3410                // Not on GitHub: contributes no targets at all.
3411                "main_repo":"local","root":"/l",
3412                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
3413            }
3414        ]});
3415        let targets = pr_targets_from_snapshot(&snapshot);
3416        assert_eq!(
3417            targets,
3418            vec![
3419                PrTarget {
3420                    owner: "rust-works".into(),
3421                    name: "omni-dev".into(),
3422                    branch: "feature".into()
3423                },
3424                PrTarget {
3425                    owner: "rust-works".into(),
3426                    name: "omni-dev".into(),
3427                    branch: "main".into()
3428                },
3429            ]
3430        );
3431    }
3432
3433    #[test]
3434    fn pr_targets_from_snapshot_is_empty_without_repos() {
3435        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
3436        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
3437    }
3438
3439    #[test]
3440    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
3441        // Defensive: a `github` object without usable owner/name strings yields no
3442        // target rather than a half-built query.
3443        for github in [
3444            json!({}),
3445            json!({"owner": "o"}),
3446            json!({"owner": 1, "name": 2}),
3447        ] {
3448            let snapshot = json!({"repos":[{
3449                "main_repo":"r","github":github,"root":"/r",
3450                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
3451            }]});
3452            assert!(
3453                pr_targets_from_snapshot(&snapshot).is_empty(),
3454                "{snapshot:?}"
3455            );
3456        }
3457    }
3458
3459    #[test]
3460    fn pr_should_fetch_on_a_moved_tree_or_an_elapsed_backoff() {
3461        let backoff = Duration::from_secs(600);
3462        // Never fetched: go.
3463        assert!(pr_should_fetch(false, None, backoff));
3464        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
3465        // spend nothing.
3466        assert!(!pr_should_fetch(
3467            false,
3468            Some(Duration::from_secs(1)),
3469            backoff
3470        ));
3471        // Quiet tree, backoff elapsed: time to look again.
3472        assert!(pr_should_fetch(false, Some(backoff), backoff));
3473        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
3474        // The load-bearing case: the tree moved (a commit landed), so fetch **now**
3475        // regardless of how deep the backoff had grown. Without this a push waits out
3476        // the full ceiling on a stale badge.
3477        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
3478        assert!(pr_should_fetch(
3479            true,
3480            Some(Duration::from_millis(1)),
3481            backoff
3482        ));
3483    }
3484
3485    #[test]
3486    fn next_pr_poll_delay_holds_fast_while_pending_and_backs_off_to_the_ceiling() {
3487        let base = Duration::from_secs(10);
3488        // Something is still running: keep the watch cadence, however long we had
3489        // backed off for.
3490        assert_eq!(next_pr_poll_delay(base, base, true), base);
3491        assert_eq!(next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, true), base);
3492        // Everything terminal: double…
3493        assert_eq!(next_pr_poll_delay(base, base, false), base * 2);
3494        assert_eq!(next_pr_poll_delay(base * 2, base, false), base * 4);
3495        // …but never past the ceiling, and never overflow off it.
3496        assert_eq!(
3497            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false),
3498            MAX_PR_POLL_INTERVAL
3499        );
3500        assert_eq!(
3501            next_pr_poll_delay(Duration::MAX, base, false),
3502            MAX_PR_POLL_INTERVAL
3503        );
3504    }
3505
3506    #[tokio::test]
3507    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
3508        let dir = tempfile::tempdir().unwrap();
3509        let repo = github_repo(dir.path());
3510        let head = repo.head().unwrap().target().unwrap().to_string();
3511        let svc = WorktreesService::new();
3512        svc.handle(
3513            "register",
3514            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3515        )
3516        .await
3517        .unwrap();
3518
3519        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon.
3520        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3521        assert!(wt.get("pr").is_none(), "{wt:?}");
3522
3523        // Seed the cache the poller writes, then re-read the tree.
3524        let mut badges = HashMap::new();
3525        badges.insert(
3526            PrTarget {
3527                owner: "rust-works".into(),
3528                name: "omni-dev".into(),
3529                branch: "main".into(),
3530            },
3531            pending_badge(1337, &head),
3532        );
3533        assert!(svc.pr_cache.replace(badges));
3534
3535        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3536        assert_eq!(wt["pr"]["number"], json!(1337));
3537        assert_eq!(wt["pr"]["checks"], json!("pending"));
3538        // camelCase on the wire, or the extension silently loses the draft marker.
3539        assert_eq!(wt["pr"]["isDraft"], json!(false));
3540    }
3541
3542    #[tokio::test]
3543    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
3544        // A detached HEAD has a commit but no branch, and a badge is keyed by
3545        // branch — so there is nothing to match. It must fall through silently
3546        // rather than borrow a badge from whatever branch happens to be cached, and
3547        // rather than sink the tree.
3548        let dir = tempfile::tempdir().unwrap();
3549        let repo = github_repo(dir.path());
3550        let head = repo.head().unwrap().target().unwrap();
3551        repo.set_head_detached(head).unwrap();
3552
3553        let svc = WorktreesService::new();
3554        svc.handle(
3555            "register",
3556            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3557        )
3558        .await
3559        .unwrap();
3560        // A badge *is* cached for `main` — the branch this worktree was on before
3561        // detaching. It must not leak onto the now-branchless row.
3562        let mut badges = HashMap::new();
3563        badges.insert(
3564            PrTarget {
3565                owner: "rust-works".into(),
3566                name: "omni-dev".into(),
3567                branch: "main".into(),
3568            },
3569            pending_badge(1, &head.to_string()),
3570        );
3571        svc.pr_cache.replace(badges);
3572
3573        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3574        assert!(wt.get("branch").is_none(), "{wt:?}");
3575        // The SHA still shows — detached means no branch, not no commit.
3576        assert_eq!(
3577            wt.get("head_sha").and_then(Value::as_str),
3578            Some(head.to_string().as_str())
3579        );
3580        assert!(wt.get("pr").is_none(), "{wt:?}");
3581    }
3582
3583    #[tokio::test]
3584    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
3585        let dir = tempfile::tempdir().unwrap();
3586        github_repo(dir.path());
3587        let svc = WorktreesService::new();
3588        svc.handle(
3589            "register",
3590            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3591        )
3592        .await
3593        .unwrap();
3594        // A badge for a different branch must not leak onto `main`.
3595        let mut badges = HashMap::new();
3596        badges.insert(
3597            PrTarget {
3598                owner: "rust-works".into(),
3599                name: "omni-dev".into(),
3600                branch: "other".into(),
3601            },
3602            pending_badge(1, "irrelevant"),
3603        );
3604        svc.pr_cache.replace(badges);
3605        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3606        assert!(wt.get("pr").is_none(), "{wt:?}");
3607    }
3608
3609    #[tokio::test]
3610    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
3611        // The idle case — the daemon runs all day with no editor open. Point it at a
3612        // stub that fails loudly if ever spawned: a poll here would both waste
3613        // GitHub budget and, on a real `gh`, wake the radio for nothing.
3614        let bin_dir = tempfile::tempdir().unwrap();
3615        let marker = bin_dir.path().join("spawned");
3616        let fake = bin_dir.path().join("fake-gh");
3617        std::fs::write(
3618            &fake,
3619            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
3620        )
3621        .unwrap();
3622        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
3623        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
3624        std::fs::set_permissions(&fake, perms).unwrap();
3625
3626        let svc = WorktreesService::new();
3627        svc.start_pr_poller_with(Duration::from_millis(20), fake);
3628        tokio::time::sleep(Duration::from_millis(200)).await;
3629        svc.shutdown().await;
3630        assert!(
3631            !marker.exists(),
3632            "the poller must not spawn gh with no windows registered"
3633        );
3634    }
3635
3636    #[tokio::test]
3637    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
3638        // Badges are decoration: an unauthenticated or broken `gh` must never sink
3639        // the tree, and one bad poll must not blank rows that were fine a second ago.
3640        let dir = tempfile::tempdir().unwrap();
3641        let repo = github_repo(dir.path());
3642        let head = repo.head().unwrap().target().unwrap().to_string();
3643        let bin_dir = tempfile::tempdir().unwrap();
3644        let fake = bin_dir.path().join("fake-gh");
3645        // Exits non-zero, exactly as `gh` does without `gh auth login`.
3646        std::fs::write(
3647            &fake,
3648            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
3649        )
3650        .unwrap();
3651        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
3652        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
3653        std::fs::set_permissions(&fake, perms).unwrap();
3654
3655        let svc = WorktreesService::new();
3656        svc.handle(
3657            "register",
3658            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3659        )
3660        .await
3661        .unwrap();
3662        // Seed a badge as though an earlier poll had succeeded.
3663        let mut seeded = HashMap::new();
3664        seeded.insert(
3665            PrTarget {
3666                owner: "rust-works".into(),
3667                name: "omni-dev".into(),
3668                branch: "main".into(),
3669            },
3670            pending_badge(7, &head),
3671        );
3672        svc.pr_cache.replace(seeded);
3673
3674        svc.start_pr_poller_with(Duration::from_millis(20), fake);
3675        tokio::time::sleep(Duration::from_millis(200)).await;
3676
3677        // The tree still serves, and the seeded badge survived the failing polls.
3678        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3679        assert_eq!(wt["pr"]["number"], json!(7));
3680        svc.shutdown().await;
3681    }
3682
3683    #[tokio::test]
3684    // The shim guard is deliberately held across the awaits below: it must span
3685    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
3686    // is against another test writing while this one forks. Safe here — only test
3687    // threads take it, never a task inside the runtime, so it cannot deadlock.
3688    // Scoped per-test rather than on the module, which would also silence the
3689    // registry lock's "never held across .await" invariant.
3690    #[allow(clippy::await_holding_lock)]
3691    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
3692        // The normal startup order: the daemon starts at login, *before* any
3693        // editor. It therefore sees an empty tree and backs off to the 30-minute
3694        // ceiling — so unless a register wakes it, the first badge of the session
3695        // arrives up to half an hour after the window does, which reads as the
3696        // feature being broken rather than slow.
3697        let dir = tempfile::tempdir().unwrap();
3698        github_repo(dir.path());
3699        let bin_dir = tempfile::tempdir().unwrap();
3700        let (fake, _shim) = fake_gh(
3701            bin_dir.path(),
3702            r#"{"data":{"r0":{"b0":{
3703                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
3704                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
3705                ]}}},
3706                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
3707            }}}}"#,
3708        );
3709
3710        let svc = WorktreesService::new();
3711        // Poller first, with nothing registered — it backs off on the empty tree.
3712        svc.start_pr_poller_with(Duration::from_millis(50), fake);
3713        tokio::time::sleep(Duration::from_millis(150)).await;
3714
3715        // Now an editor opens.
3716        svc.handle(
3717            "register",
3718            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3719        )
3720        .await
3721        .unwrap();
3722
3723        // The badge must follow promptly — the register wakes the loop out of its
3724        // backoff. The deadline is orders of magnitude below the ceiling, so this
3725        // fails on the bug rather than merely being slow.
3726        let badge = tokio::time::timeout(Duration::from_secs(30), async {
3727            loop {
3728                if let Some(badge) = svc.pr_cache.get("rust-works", "omni-dev", "main") {
3729                    return badge;
3730                }
3731                tokio::time::sleep(Duration::from_millis(25)).await;
3732            }
3733        })
3734        .await
3735        .expect("a window opening must wake the poller out of its idle backoff");
3736        assert_eq!(badge.number, 99);
3737        svc.shutdown().await;
3738    }
3739
3740    #[tokio::test]
3741    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
3742        // The acceptance criterion: "pushing a new commit invalidates the badge
3743        // rather than leaving the previous head's verdict standing."
3744        //
3745        // The cache still holds the verdict for the *old* commit, and the poller may
3746        // have backed off for up to half an hour. So the fold — which runs on every
3747        // snapshot — has to notice on its own, with no network call.
3748        let dir = tempfile::tempdir().unwrap();
3749        let repo = github_repo(dir.path());
3750        let first = repo.head().unwrap().target().unwrap();
3751
3752        let svc = WorktreesService::new();
3753        svc.handle(
3754            "register",
3755            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3756        )
3757        .await
3758        .unwrap();
3759
3760        // A green verdict, correctly describing the commit currently checked out.
3761        let mut badges = HashMap::new();
3762        badges.insert(
3763            PrTarget {
3764                owner: "rust-works".into(),
3765                name: "omni-dev".into(),
3766                branch: "main".into(),
3767            },
3768            PrBadge {
3769                number: 1337,
3770                is_draft: false,
3771                checks: PrCheckState::Success,
3772                url: "u".into(),
3773                head_oid: first.to_string(),
3774            },
3775        );
3776        svc.pr_cache.replace(badges);
3777
3778        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3779        assert_eq!(
3780            wt["pr"]["checks"],
3781            json!("success"),
3782            "green for its own commit"
3783        );
3784
3785        // Now commit — as a push would leave things, with the cache untouched.
3786        let head = repo.find_commit(first).unwrap();
3787        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
3788
3789        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3790        assert_eq!(
3791            wt["pr"]["checks"],
3792            json!("pending"),
3793            "the previous commit's ✓ must not stand after a new commit"
3794        );
3795        // The PR itself is still shown — it is the *verdict* that is unknown, not
3796        // the PR.
3797        assert_eq!(wt["pr"]["number"], json!(1337));
3798    }
3799
3800    #[test]
3801    fn is_stale_for_compares_the_commit_the_verdict_describes() {
3802        let badge = pending_badge(1, "aaa");
3803        assert!(!badge.is_stale_for(Some("aaa")));
3804        assert!(badge.is_stale_for(Some("bbb")));
3805        // No local HEAD (unborn): nothing to compare against, so not stale.
3806        assert!(!badge.is_stale_for(None));
3807    }
3808
3809    #[test]
3810    fn pr_watch_tracks_the_head_so_a_commit_is_visible_to_the_poller() {
3811        let snap = |sha: &str| {
3812            json!({"repos":[{
3813                "main_repo":"omni-dev",
3814                "github":{"owner":"rust-works","name":"omni-dev"},
3815                "root":"/r",
3816                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
3817            }]})
3818        };
3819        let before = pr_watch_from_snapshot(&snap("aaa"));
3820        let after = pr_watch_from_snapshot(&snap("bbb"));
3821        // Same target, different head — the poller's "something moved" signal.
3822        assert_eq!(before.len(), 1);
3823        assert_eq!(before[0].target, after[0].target);
3824        assert_ne!(before, after);
3825        // Identical trees compare equal, so a quiet tick asks nothing.
3826        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
3827    }
3828
3829    #[test]
3830    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
3831        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
3832        // it moves no local head — so watching only `head_sha` left the poller
3833        // blind to it and the badge sat at `●` until the backoff elapsed, up to
3834        // its 30-minute ceiling.
3835        let snap = |upstream: &str| {
3836            json!({"repos":[{
3837                "main_repo":"omni-dev",
3838                "github":{"owner":"rust-works","name":"omni-dev"},
3839                "root":"/r",
3840                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
3841                              "upstream_sha":upstream,"is_main":true,"open":true}]
3842            }]})
3843        };
3844        let before = pr_watch_from_snapshot(&snap("aaa"));
3845        let after = pr_watch_from_snapshot(&snap("bbb"));
3846        // Same target, same head — only the upstream moved, and that alone must
3847        // register as "go and ask now".
3848        assert_eq!(before.len(), 1);
3849        assert_eq!(before[0].target, after[0].target);
3850        assert_eq!(before[0].head_sha, after[0].head_sha);
3851        assert_ne!(before, after);
3852        // A quiet tick still asks nothing.
3853        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
3854    }
3855
3856    #[test]
3857    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
3858        // An older daemon — or any branch tracking nothing — simply sends no
3859        // `upstream_sha`, which reads as `None` rather than failing the poll.
3860        let snap = json!({"repos":[{
3861            "main_repo":"omni-dev",
3862            "github":{"owner":"rust-works","name":"omni-dev"},
3863            "root":"/r",
3864            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
3865        }]});
3866        let watch = pr_watch_from_snapshot(&snap);
3867        assert_eq!(watch.len(), 1);
3868        assert_eq!(watch[0].upstream_sha, None);
3869        assert_eq!(watch[0].head_sha.as_deref(), Some("aaa"));
3870    }
3871
3872    #[test]
3873    fn start_pr_poller_is_a_noop_outside_a_runtime() {
3874        let svc = WorktreesService::new();
3875        svc.start_pr_poller();
3876        assert!(svc
3877            .poller
3878            .lock()
3879            .unwrap_or_else(PoisonError::into_inner)
3880            .is_none());
3881    }
3882
3883    #[tokio::test]
3884    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
3885        let svc = WorktreesService::new();
3886        svc.start_pr_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
3887        let token = svc
3888            .poller
3889            .lock()
3890            .unwrap_or_else(PoisonError::into_inner)
3891            .as_ref()
3892            .map(|t| t.token.clone())
3893            .expect("poller started");
3894
3895        // Cancel the live task, then start again: if `start` spawned a replacement
3896        // it would orphan this one, so the token staying cancelled proves it did not.
3897        token.cancel();
3898        svc.start_pr_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
3899        assert!(svc
3900            .poller
3901            .lock()
3902            .unwrap_or_else(PoisonError::into_inner)
3903            .as_ref()
3904            .is_some_and(|t| t.token.is_cancelled()));
3905
3906        svc.shutdown().await;
3907        assert!(svc
3908            .poller
3909            .lock()
3910            .unwrap_or_else(PoisonError::into_inner)
3911            .is_none());
3912    }
3913
3914    #[tokio::test]
3915    // Holds the shim guard across awaits; see the note above.
3916    #[allow(clippy::await_holding_lock)]
3917    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
3918        let dir = tempfile::tempdir().unwrap();
3919        github_repo(dir.path());
3920        let bin_dir = tempfile::tempdir().unwrap();
3921        // One repo, one branch → aliases r0/b0. A still-running check so the badge
3922        // stays pending and the loop keeps its fast cadence.
3923        let (fake, _shim) = fake_gh(
3924            bin_dir.path(),
3925            r#"{"data":{"r0":{"b0":{
3926                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
3927                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
3928                ]}}},
3929                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
3930            }}}}"#,
3931        );
3932        let svc = WorktreesService::new();
3933        svc.handle(
3934            "register",
3935            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3936        )
3937        .await
3938        .unwrap();
3939        svc.start_pr_poller_with(Duration::from_millis(50), fake.clone());
3940
3941        // Wait on a generous wall-clock deadline: each poll spawns a real
3942        // subprocess, and under a loaded machine (a full `build.sh` runs a build
3943        // and clippy alongside) a tight budget flakes rather than fails honestly.
3944        let badge = tokio::time::timeout(Duration::from_secs(30), async {
3945            loop {
3946                if let Some(badge) = svc.pr_cache.get("rust-works", "omni-dev", "main") {
3947                    return badge;
3948                }
3949                tokio::time::sleep(Duration::from_millis(25)).await;
3950            }
3951        })
3952        .await
3953        .expect("poller should resolve a badge through the fake gh");
3954        assert_eq!(badge.number, 1337);
3955        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
3956
3957        // The badge reaches the wire the windows actually read.
3958        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3959        assert_eq!(wt["pr"]["number"], json!(1337));
3960
3961        // And the loop is quiescent after shutdown: the generation must stop moving.
3962        svc.shutdown().await;
3963        let generation = svc.registry.change_generation();
3964        tokio::time::sleep(Duration::from_millis(120)).await;
3965        assert_eq!(
3966            svc.registry.change_generation(),
3967            generation,
3968            "no bumps after shutdown"
3969        );
3970    }
3971
3972    #[tokio::test]
3973    // Holds the shim guard across awaits; see the note above.
3974    #[allow(clippy::await_holding_lock)]
3975    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
3976        // The diff-and-drop contract: an unchanged poll must not bump, or every
3977        // window re-renders on every tick — the cost this design exists to avoid.
3978        let dir = tempfile::tempdir().unwrap();
3979        github_repo(dir.path());
3980        let bin_dir = tempfile::tempdir().unwrap();
3981        let (fake, _shim) = fake_gh(
3982            bin_dir.path(),
3983            r#"{"data":{"r0":{"b0":{
3984                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
3985                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
3986                ]}}},
3987                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
3988            }}}}"#,
3989        );
3990        let svc = WorktreesService::new();
3991        svc.handle(
3992            "register",
3993            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3994        )
3995        .await
3996        .unwrap();
3997        svc.start_pr_poller_with(Duration::from_millis(50), fake.clone());
3998
3999        tokio::time::timeout(Duration::from_secs(30), async {
4000            loop {
4001                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
4002                    return;
4003                }
4004                tokio::time::sleep(Duration::from_millis(25)).await;
4005            }
4006        })
4007        .await
4008        .expect("poller should resolve a badge through the fake gh");
4009        // The fake always answers identically, so after the first resolve every
4010        // subsequent poll is a no-change and must leave the generation alone.
4011        let settled = svc.registry.change_generation();
4012        tokio::time::sleep(Duration::from_millis(150)).await;
4013        assert_eq!(
4014            svc.registry.change_generation(),
4015            settled,
4016            "an unchanged poll must not bump the change-notify"
4017        );
4018        svc.shutdown().await;
4019    }
4020
4021    #[test]
4022    fn sync_indicator_formats_only_with_upstream() {
4023        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
4024        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
4025        assert_eq!(sync_indicator(None, None), None);
4026        // A partial pair (no real upstream) yields nothing.
4027        assert_eq!(sync_indicator(Some(1), None), None);
4028    }
4029
4030    #[tokio::test]
4031    async fn list_enriches_entries_with_git_status() {
4032        let dir = tempfile::tempdir().unwrap();
4033        let repo = init_repo(dir.path());
4034        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4035        repo.set_head("refs/heads/main").unwrap();
4036
4037        let svc = WorktreesService::new();
4038        svc.handle(
4039            "register",
4040            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
4041        )
4042        .await
4043        .unwrap();
4044        let payload = svc.handle("list", Value::Null).await.unwrap();
4045        let windows = windows_of(&payload);
4046        assert_eq!(windows.len(), 1);
4047        assert_eq!(
4048            windows[0].get("branch").and_then(Value::as_str),
4049            Some("main")
4050        );
4051        // No upstream configured → the ahead/behind keys are absent, not zero.
4052        assert!(windows[0].get("ahead").is_none());
4053        assert!(windows[0].get("behind").is_none());
4054        // The main repo name is enriched onto the entry.
4055        assert_eq!(
4056            windows[0].get("main_repo").and_then(Value::as_str),
4057            dir.path().file_name().and_then(|n| n.to_str())
4058        );
4059
4060        // A non-repo folder is still listed, just without a branch or main repo.
4061        let plain = tempfile::tempdir().unwrap();
4062        svc.handle(
4063            "register",
4064            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
4065        )
4066        .await
4067        .unwrap();
4068        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
4069        let w2 = windows
4070            .iter()
4071            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
4072            .unwrap();
4073        assert!(w2.get("branch").is_none());
4074        assert!(w2.get("main_repo").is_none());
4075    }
4076
4077    #[test]
4078    fn window_label_prefers_git_branch_over_title() {
4079        let dir = tempfile::tempdir().unwrap();
4080        let repo = init_repo(dir.path());
4081        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4082        repo.set_head("refs/heads/main").unwrap();
4083        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
4084        let entry = WindowEntry {
4085            key: "k".to_string(),
4086            folders: vec![dir.path().to_path_buf()],
4087            // Both the companion `repo` and `title` are overridden by the
4088            // git-derived main repo name and computed branch.
4089            repo: Some("companion-repo".to_string()),
4090            title: Some("ignored title".to_string()),
4091            pid: None,
4092            last_seen: Utc::now(),
4093        };
4094        // Main checkout: `repo · branch`, and with no upstream there is no sync.
4095        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
4096    }
4097
4098    #[tokio::test]
4099    async fn list_includes_ahead_behind_for_tracking_branch() {
4100        let dir = tempfile::tempdir().unwrap();
4101        let _repo = diverging_repo(dir.path());
4102
4103        let svc = WorktreesService::new();
4104        svc.handle(
4105            "register",
4106            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
4107        )
4108        .await
4109        .unwrap();
4110        let payload = svc.handle("list", Value::Null).await.unwrap();
4111        let windows = windows_of(&payload);
4112        // A tracking branch serializes branch plus both divergence counts.
4113        assert_eq!(
4114            windows[0].get("branch").and_then(Value::as_str),
4115            Some("main")
4116        );
4117        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
4118        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
4119    }
4120
4121    #[test]
4122    fn window_label_includes_sync_for_tracking_branch() {
4123        let dir = tempfile::tempdir().unwrap();
4124        let _repo = diverging_repo(dir.path());
4125        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
4126        let entry = WindowEntry {
4127            key: "k".to_string(),
4128            folders: vec![dir.path().to_path_buf()],
4129            repo: Some("companion-repo".to_string()),
4130            title: None,
4131            pid: None,
4132            last_seen: Utc::now(),
4133        };
4134        // A tracking branch appends the `(+ahead -behind)` sync indicator.
4135        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
4136    }
4137
4138    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
4139    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
4140    /// <wt_path>`.
4141    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
4142        let commit = repo.find_commit(base).unwrap();
4143        repo.branch(branch, &commit, false).unwrap();
4144        let reference = repo
4145            .find_reference(&format!("refs/heads/{branch}"))
4146            .unwrap();
4147        let mut opts = git2::WorktreeAddOptions::new();
4148        opts.reference(Some(&reference));
4149        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
4150    }
4151
4152    #[test]
4153    fn git_status_marks_linked_worktree_and_names_parent_repo() {
4154        let main_dir = tempfile::tempdir().unwrap();
4155        let repo = init_repo(main_dir.path());
4156        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4157        repo.set_head("refs/heads/main").unwrap();
4158
4159        // A linked worktree checked out on a new `feature` branch, in a
4160        // directory whose basename is deliberately *not* the repo name.
4161        let wt_parent = tempfile::tempdir().unwrap();
4162        let wt_path = wt_parent.path().join("feature-wt");
4163        add_worktree(&repo, a, &wt_path, "feature");
4164
4165        let status = git_status(&wt_path);
4166        assert!(status.is_worktree);
4167        assert_eq!(status.branch.as_deref(), Some("feature"));
4168        // The worktree names its *parent* repo, not its worktree-folder basename.
4169        assert_eq!(
4170            status.main_repo.as_deref(),
4171            main_dir.path().file_name().and_then(|n| n.to_str())
4172        );
4173
4174        // The main checkout resolves the same repo name and is not a worktree.
4175        let main_status = git_status(main_dir.path());
4176        assert!(!main_status.is_worktree);
4177        assert_eq!(main_status.main_repo, status.main_repo);
4178    }
4179
4180    #[test]
4181    fn window_label_marks_worktree_with_fork_glyph() {
4182        let main_dir = tempfile::tempdir().unwrap();
4183        let repo = init_repo(main_dir.path());
4184        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4185        repo.set_head("refs/heads/main").unwrap();
4186        let wt_parent = tempfile::tempdir().unwrap();
4187        let wt_path = wt_parent.path().join("feature-wt");
4188        add_worktree(&repo, a, &wt_path, "feature");
4189
4190        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
4191        let entry = WindowEntry {
4192            key: "k".to_string(),
4193            folders: vec![wt_path],
4194            repo: Some("feature-wt".to_string()),
4195            title: None,
4196            pid: None,
4197            last_seen: Utc::now(),
4198        };
4199        // A worktree line: parent repo, the fork glyph, then the branch (no
4200        // upstream here, so no sync suffix).
4201        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
4202    }
4203
4204    #[test]
4205    fn main_repo_name_derives_from_common_dir() {
4206        // Normal layout: the repo is the directory that contains `.git`.
4207        assert_eq!(
4208            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
4209            Some("omni-dev")
4210        );
4211        // A trailing slash on the common dir does not change the answer.
4212        assert_eq!(
4213            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
4214            Some("omni-dev")
4215        );
4216        // A bare repo: its own directory name, without the `.git` suffix.
4217        assert_eq!(
4218            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
4219            Some("omni-dev")
4220        );
4221        // A `.git` at the filesystem root has no parent name to use.
4222        assert_eq!(main_repo_name(Path::new("/.git")), None);
4223    }
4224
4225    // --- Repo/worktree tree (#1265) ----------------------------------------
4226
4227    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
4228    /// temporary payload).
4229    fn repos_of(payload: &Value) -> Vec<Value> {
4230        payload
4231            .get("repos")
4232            .and_then(Value::as_array)
4233            .expect("repos array")
4234            .clone()
4235    }
4236
4237    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
4238        Some(GithubIdentity {
4239            owner: owner.to_string(),
4240            name: name.to_string(),
4241        })
4242    }
4243
4244    #[test]
4245    fn github_identity_parses_supported_forms() {
4246        // https / http, with and without the `.git` suffix.
4247        assert_eq!(
4248            github_identity("https://github.com/rust-works/omni-dev.git"),
4249            github("rust-works", "omni-dev")
4250        );
4251        assert_eq!(
4252            github_identity("https://github.com/rust-works/omni-dev"),
4253            github("rust-works", "omni-dev")
4254        );
4255        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
4256        // SCP-like and ssh:// / git:// forms.
4257        assert_eq!(
4258            github_identity("git@github.com:rust-works/omni-dev.git"),
4259            github("rust-works", "omni-dev")
4260        );
4261        assert_eq!(
4262            github_identity("ssh://git@github.com/o/r.git"),
4263            github("o", "r")
4264        );
4265        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
4266        // A trailing slash and surrounding whitespace are tolerated.
4267        assert_eq!(
4268            github_identity("  https://github.com/o/r/  "),
4269            github("o", "r")
4270        );
4271    }
4272
4273    #[test]
4274    fn github_identity_rejects_non_github_and_malformed() {
4275        // Non-GitHub hosts.
4276        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
4277        assert_eq!(github_identity("git@example.com:o/r.git"), None);
4278        // Missing or extra path segments.
4279        assert_eq!(github_identity("https://github.com/onlyowner"), None);
4280        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
4281        assert_eq!(github_identity("https://github.com/"), None);
4282        // Not a URL at all.
4283        assert_eq!(github_identity("not a url"), None);
4284    }
4285
4286    #[test]
4287    fn remote_github_identity_reads_origin_then_falls_back() {
4288        let dir = tempfile::tempdir().unwrap();
4289        let repo = init_repo(dir.path());
4290        // No remotes → None.
4291        assert_eq!(remote_github_identity(&repo), None);
4292        // A non-GitHub origin is not a match.
4293        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
4294        assert_eq!(remote_github_identity(&repo), None);
4295        // A GitHub origin resolves to its identity.
4296        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
4297            .unwrap();
4298        assert_eq!(
4299            remote_github_identity(&repo),
4300            github("rust-works", "omni-dev")
4301        );
4302
4303        // Origin non-GitHub but another remote is GitHub: the fallback loop over
4304        // the remaining remotes finds it.
4305        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
4306            .unwrap();
4307        repo.remote("upstream", "https://github.com/other/proj.git")
4308            .unwrap();
4309        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
4310    }
4311
4312    #[tokio::test]
4313    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
4314        let svc = WorktreesService::new();
4315        // No windows → an empty repo set (not an error), toggle at its default.
4316        assert_eq!(
4317            svc.handle("tree", Value::Null).await.unwrap(),
4318            json!({ "repos": [], "show_closed": true })
4319        );
4320        // A plain non-repo folder is skipped rather than sinking the op.
4321        let plain = tempfile::tempdir().unwrap();
4322        svc.handle(
4323            "register",
4324            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
4325        )
4326        .await
4327        .unwrap();
4328        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
4329    }
4330
4331    #[tokio::test]
4332    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
4333        let main_dir = tempfile::tempdir().unwrap();
4334        let repo = init_repo(main_dir.path());
4335        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4336        repo.set_head("refs/heads/main").unwrap();
4337        // A GitHub origin so the repo carries an identity in the payload.
4338        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
4339            .unwrap();
4340
4341        // A linked worktree on a new `feature` branch, in a directory whose
4342        // basename is deliberately not the repo name.
4343        let wt_parent = tempfile::tempdir().unwrap();
4344        let wt_path = wt_parent.path().join("feature-wt");
4345        add_worktree(&repo, a, &wt_path, "feature");
4346
4347        let svc = WorktreesService::new();
4348        // A window open on the main checkout and one on the linked worktree —
4349        // two windows, but one repo (they must dedupe).
4350        svc.handle(
4351            "register",
4352            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
4353        )
4354        .await
4355        .unwrap();
4356        svc.handle(
4357            "register",
4358            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
4359        )
4360        .await
4361        .unwrap();
4362
4363        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
4364        assert_eq!(
4365            repos.len(),
4366            1,
4367            "two worktrees of one repo dedupe: {repos:?}"
4368        );
4369        let repo0 = &repos[0];
4370        // Repo identity is the parent-repo name (not a worktree-folder basename).
4371        assert_eq!(
4372            repo0.get("main_repo").and_then(Value::as_str),
4373            main_dir.path().file_name().and_then(|n| n.to_str())
4374        );
4375        assert_eq!(
4376            repo0.pointer("/github/owner").and_then(Value::as_str),
4377            Some("rust-works")
4378        );
4379        assert_eq!(
4380            repo0.pointer("/github/name").and_then(Value::as_str),
4381            Some("omni-dev")
4382        );
4383        assert!(repo0.get("root").and_then(Value::as_str).is_some());
4384
4385        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
4386        assert_eq!(worktrees.len(), 2);
4387        // Main working tree first: is_main, open, with the main window's key.
4388        let main_wt = &worktrees[0];
4389        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
4390        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
4391        assert_eq!(
4392            main_wt.get("window_key").and_then(Value::as_str),
4393            Some("wm")
4394        );
4395        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
4396        // Linked worktree: not main, open via the feature window.
4397        let linked = &worktrees[1];
4398        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
4399        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
4400        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
4401        assert_eq!(
4402            linked.get("branch").and_then(Value::as_str),
4403            Some("feature")
4404        );
4405    }
4406
4407    #[tokio::test]
4408    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
4409        let main_dir = tempfile::tempdir().unwrap();
4410        let repo = init_repo(main_dir.path());
4411        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4412        repo.set_head("refs/heads/main").unwrap();
4413        // No remote at all → the repo carries no `github` identity.
4414        let wt_parent = tempfile::tempdir().unwrap();
4415        let wt_path = wt_parent.path().join("feature-wt");
4416        add_worktree(&repo, a, &wt_path, "feature");
4417
4418        let svc = WorktreesService::new();
4419        // Only the main checkout has a window; the linked worktree has none.
4420        svc.handle(
4421            "register",
4422            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
4423        )
4424        .await
4425        .unwrap();
4426
4427        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
4428        assert_eq!(repos.len(), 1);
4429        assert!(repos[0].get("github").is_none(), "no remote → no github");
4430        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
4431        let linked = worktrees
4432            .iter()
4433            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
4434            .expect("the linked worktree");
4435        // Enumerated even though no window has it open, and marked closed.
4436        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
4437        assert!(linked.get("window_key").is_none());
4438    }
4439
4440    // --- Close op (#1277) --------------------------------------------------
4441
4442    /// Builds a repo whose main working tree is on `trunk` with one **clean**
4443    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
4444    /// paths stay valid) and the linked worktree path.
4445    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
4446        let main_dir = tempfile::tempdir().unwrap();
4447        let repo = init_repo(main_dir.path());
4448        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
4449        repo.set_head("refs/heads/trunk").unwrap();
4450        let wt_parent = tempfile::tempdir().unwrap();
4451        let wt_path = wt_parent.path().join("feature-wt");
4452        add_worktree(&repo, a, &wt_path, "feature");
4453        (main_dir, wt_parent, wt_path)
4454    }
4455
4456    #[tokio::test]
4457    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
4458        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4459        let svc = WorktreesService::new();
4460        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
4461        // main, no risks → the extension proceeds with no dialog.
4462        let report = svc
4463            .handle("close", json!({ "path": wt_path, "remove": true }))
4464            .await
4465            .unwrap();
4466        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
4467        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
4468        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
4469        assert!(report
4470            .get("risks")
4471            .and_then(Value::as_array)
4472            .unwrap()
4473            .is_empty());
4474        // No side effects: the worktree still exists.
4475        assert!(wt_path.exists());
4476    }
4477
4478    #[tokio::test]
4479    async fn close_removes_a_clean_linked_worktree() {
4480        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4481        let svc = WorktreesService::new();
4482        let reply = svc
4483            .handle(
4484                "close",
4485                json!({ "path": wt_path, "remove": true, "confirmed": true }),
4486            )
4487            .await
4488            .unwrap();
4489        assert_eq!(reply, json!({ "removed": true }));
4490        assert!(
4491            !wt_path.exists(),
4492            "the worktree directory should be deleted"
4493        );
4494    }
4495
4496    #[test]
4497    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
4498        // The reorder (#1315) must still fully remove a worktree: both the
4499        // checked-out directory *and* the admin metadata git tracks it by, so it
4500        // no longer appears in `Repository::worktrees()`.
4501        let (main, _wtp, wt_path) = repo_with_linked_worktree();
4502        let admin = main.path().join(".git").join("worktrees").join("feature");
4503        assert!(admin.exists(), "admin metadata should exist before removal");
4504
4505        remove_worktree(&wt_path).unwrap();
4506
4507        assert!(!wt_path.exists(), "the working directory should be gone");
4508        assert!(!admin.exists(), "the admin metadata should be pruned");
4509        let main_repo = Repository::open(main.path()).unwrap();
4510        assert_eq!(
4511            main_repo.worktrees().unwrap().len(),
4512            0,
4513            "git should no longer track the worktree"
4514        );
4515    }
4516
4517    #[test]
4518    fn remove_worktree_recovers_a_half_removed_orphan() {
4519        // The exact #1315 leftover: the old ordering deleted the admin metadata
4520        // first, then failed to rmdir the working tree, orphaning the directory
4521        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
4522        // rather than error with "not a git worktree".
4523        let (main, _wtp, wt_path) = repo_with_linked_worktree();
4524        let admin = main.path().join(".git").join("worktrees").join("feature");
4525        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
4526        std::fs::remove_dir_all(&admin).unwrap();
4527        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
4528        assert!(
4529            Repository::open(&wt_path).is_err(),
4530            "the orphan should not open as a repo"
4531        );
4532
4533        remove_worktree(&wt_path).unwrap();
4534        assert!(
4535            !wt_path.exists(),
4536            "the leftover directory should be removed"
4537        );
4538    }
4539
4540    #[test]
4541    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
4542        let (main, _wtp, wt_path) = repo_with_linked_worktree();
4543        // A live worktree: gitlink resolves → not an orphan.
4544        assert!(!is_orphaned_worktree(&wt_path));
4545        // The main checkout has a `.git` directory → not an orphan.
4546        assert!(!is_orphaned_worktree(main.path()));
4547        // Drop the admin metadata → the gitlink now dangles → orphan.
4548        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
4549            .unwrap();
4550        assert!(is_orphaned_worktree(&wt_path));
4551    }
4552
4553    #[test]
4554    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
4555        let tmp = tempfile::tempdir().unwrap();
4556        let missing = tmp.path().join("gone");
4557        assert!(remove_dir_all_retrying(&missing).is_ok());
4558    }
4559
4560    #[test]
4561    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
4562        use std::io::Error;
4563        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
4564            assert!(
4565                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
4566                "errno {errno} is the concurrent-writer race and must be retried"
4567            );
4568        }
4569        // A hard failure must surface immediately rather than burn the backoff
4570        // waiting for a condition that will never clear.
4571        for errno in [
4572            nix::libc::EACCES,
4573            nix::libc::EPERM,
4574            nix::libc::EROFS,
4575            nix::libc::ENOTDIR,
4576        ] {
4577            assert!(
4578                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
4579                "errno {errno} is permanent and must not be retried"
4580            );
4581        }
4582        // Not from the OS at all, so there is no errno to classify.
4583        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
4584    }
4585
4586    #[test]
4587    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
4588        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
4589        // race, so it must fail on the first attempt with the original cause
4590        // attached, leaving the path untouched.
4591        let tmp = tempfile::tempdir().unwrap();
4592        let file = tmp.path().join("not-a-directory");
4593        std::fs::write(&file, b"x").unwrap();
4594
4595        let mut attempts = 0;
4596        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
4597            attempts += 1;
4598            std::fs::remove_dir_all(&file)
4599        })
4600        .unwrap_err();
4601
4602        assert_eq!(attempts, 1, "a permanent error must not be retried");
4603        assert!(
4604            err.to_string()
4605                .contains("failed to remove worktree directory"),
4606            "unexpected error: {err:#}"
4607        );
4608        assert!(err.source().is_some(), "the io::Error cause is preserved");
4609        assert!(file.exists());
4610    }
4611
4612    #[test]
4613    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
4614        // A writer that never quiesces: every sweep re-finds the directory
4615        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
4616        // than the loop spinning forever.
4617        let tmp = tempfile::tempdir().unwrap();
4618        let mut attempts = 0;
4619        let backoff = [Duration::ZERO, Duration::ZERO];
4620        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
4621            attempts += 1;
4622            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
4623        })
4624        .unwrap_err();
4625
4626        // One attempt per delay, plus the initial one.
4627        assert_eq!(attempts, backoff.len() + 1);
4628        assert!(
4629            err.to_string()
4630                .contains("failed to remove worktree directory"),
4631            "unexpected error: {err:#}"
4632        );
4633    }
4634
4635    #[test]
4636    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
4637        // The #1315 happy path, deterministically: the race clears partway through
4638        // the schedule and the removal then succeeds.
4639        let tmp = tempfile::tempdir().unwrap();
4640        let mut attempts = 0;
4641        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
4642            attempts += 1;
4643            if attempts < 3 {
4644                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
4645            } else {
4646                Ok(())
4647            }
4648        });
4649        assert!(result.is_ok(), "{result:?}");
4650        assert_eq!(attempts, 3);
4651    }
4652
4653    #[test]
4654    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
4655        // A `.git` file that is readable but carries no `gitdir:` pointer is not
4656        // something we may delete.
4657        let tmp = tempfile::tempdir().unwrap();
4658        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
4659        assert!(!is_orphaned_worktree(tmp.path()));
4660    }
4661
4662    #[test]
4663    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
4664        // Neither a repo nor an orphan: refuse it rather than recursively deleting
4665        // whatever directory was passed in.
4666        let tmp = tempfile::tempdir().unwrap();
4667        let plain = tmp.path().join("plain");
4668        std::fs::create_dir(&plain).unwrap();
4669
4670        let err = remove_worktree(&plain).unwrap_err();
4671
4672        assert!(
4673            err.to_string().contains("not a git worktree"),
4674            "unexpected error: {err:#}"
4675        );
4676        assert!(plain.exists(), "a non-worktree path must be left alone");
4677    }
4678
4679    #[test]
4680    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
4681        // Acceptance criterion (#1315): a language server / cargo still writing
4682        // into `target/` as the window closes makes the recursive rmdir race with
4683        // "Directory not empty". A background thread reproduces that by
4684        // repopulating `target/` for a bounded window; removal must retry past it
4685        // and still succeed once the writer stops.
4686        use std::sync::atomic::{AtomicBool, Ordering};
4687        use std::sync::Arc;
4688
4689        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4690        let target = wt_path.join("target");
4691        std::fs::create_dir_all(&target).unwrap();
4692
4693        let stop = Arc::new(AtomicBool::new(false));
4694        let writer_stop = Arc::clone(&stop);
4695        let writer_dir = target;
4696        let writer = std::thread::spawn(move || {
4697            let mut n = 0u64;
4698            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
4699            // stop so a later removal pass finds the directory quiescent.
4700            let deadline = std::time::Instant::now() + Duration::from_millis(400);
4701            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
4702                let nested = writer_dir.join("nested");
4703                // Best-effort: the parent may be mid-deletion — ignore failures.
4704                let _ = std::fs::create_dir_all(&nested);
4705                let _ = std::fs::write(nested.join(format!("artifact-{n}.tmp")), b"x");
4706                n += 1;
4707            }
4708        });
4709
4710        let result = remove_worktree(&wt_path);
4711        stop.store(true, Ordering::Relaxed);
4712        writer.join().unwrap();
4713
4714        assert!(
4715            result.is_ok(),
4716            "removal should retry past the writer: {result:?}"
4717        );
4718        assert!(!wt_path.exists(), "the worktree directory should be gone");
4719    }
4720
4721    #[tokio::test]
4722    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
4723        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4724        // An untracked file in the worktree would be lost on removal.
4725        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
4726
4727        let svc = WorktreesService::new();
4728        let report = svc
4729            .handle("close", json!({ "path": wt_path, "remove": true }))
4730            .await
4731            .unwrap();
4732        let risks = report.get("risks").and_then(Value::as_array).unwrap();
4733        assert!(
4734            risks
4735                .iter()
4736                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
4737            "expected an untracked risk: {report}"
4738        );
4739        // Still removable — the risk only means "confirm first", not "refuse".
4740        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
4741        // The unconfirmed check has no side effects.
4742        assert!(wt_path.exists());
4743    }
4744
4745    #[tokio::test]
4746    async fn close_confirmed_removes_a_dirty_worktree() {
4747        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4748        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
4749        let svc = WorktreesService::new();
4750        // With confirmation, the risks are overridden and removal proceeds.
4751        let reply = svc
4752            .handle(
4753                "close",
4754                json!({ "path": wt_path, "remove": true, "confirmed": true }),
4755            )
4756            .await
4757            .unwrap();
4758        assert_eq!(reply, json!({ "removed": true }));
4759        assert!(!wt_path.exists());
4760    }
4761
4762    #[tokio::test]
4763    async fn close_refuses_to_remove_the_main_working_tree() {
4764        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
4765        let svc = WorktreesService::new();
4766        // Phase 1: the main tree reports not-removable, marked main.
4767        let report = svc
4768            .handle("close", json!({ "path": main.path(), "remove": true }))
4769            .await
4770            .unwrap();
4771        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
4772        assert_eq!(
4773            report.get("removable").and_then(Value::as_bool),
4774            Some(false)
4775        );
4776        // Phase 2: even a confirmed delete of the main tree is refused
4777        // defensively, and the directory is untouched.
4778        assert!(svc
4779            .handle(
4780                "close",
4781                json!({ "path": main.path(), "remove": true, "confirmed": true }),
4782            )
4783            .await
4784            .is_err());
4785        assert!(main.path().exists());
4786    }
4787
4788    #[tokio::test]
4789    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
4790        // The case a naive impl would wrongly protect: a linked worktree checked
4791        // out on `main` (the default branch) is *still a linked worktree*, so it
4792        // is fully deletable — and `main` survives (removal never deletes a branch).
4793        let main_dir = tempfile::tempdir().unwrap();
4794        let repo = init_repo(main_dir.path());
4795        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
4796        repo.set_head("refs/heads/trunk").unwrap();
4797        let wt_parent = tempfile::tempdir().unwrap();
4798        let wt_path = wt_parent.path().join("main-wt");
4799        add_worktree(&repo, a, &wt_path, "main");
4800
4801        let svc = WorktreesService::new();
4802        let reply = svc
4803            .handle(
4804                "close",
4805                json!({ "path": wt_path, "remove": true, "confirmed": true }),
4806            )
4807            .await
4808            .unwrap();
4809        assert_eq!(reply, json!({ "removed": true }));
4810        assert!(!wt_path.exists());
4811        // The `main` branch is untouched by the worktree removal.
4812        assert!(
4813            repo.find_branch("main", git2::BranchType::Local).is_ok(),
4814            "the default branch must survive worktree removal"
4815        );
4816    }
4817
4818    #[tokio::test]
4819    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
4820        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4821        let svc = WorktreesService::new();
4822        // First removal succeeds.
4823        svc.handle(
4824            "close",
4825            json!({ "path": wt_path, "remove": true, "confirmed": true }),
4826        )
4827        .await
4828        .unwrap();
4829        // A second confirmed close of the now-missing path is a clean success,
4830        // not an error (a stale snapshot must not crash).
4831        let reply = svc
4832            .handle(
4833                "close",
4834                json!({ "path": wt_path, "remove": true, "confirmed": true }),
4835            )
4836            .await
4837            .unwrap();
4838        assert_eq!(reply, json!({ "removed": true }));
4839    }
4840
4841    #[tokio::test]
4842    async fn close_safety_check_detects_detached_head_unreachable_commits() {
4843        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4844        // In the worktree, commit onto a detached HEAD so the new commit is
4845        // reachable from no ref — it would be GC'd on removal.
4846        let wt_repo = Repository::open(&wt_path).unwrap();
4847        let parent_oid = wt_repo.head().unwrap().target().unwrap();
4848        let parent = wt_repo.find_commit(parent_oid).unwrap();
4849        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
4850        wt_repo.set_head_detached(orphan).unwrap();
4851
4852        let svc = WorktreesService::new();
4853        let report = svc
4854            .handle("close", json!({ "path": wt_path, "remove": true }))
4855            .await
4856            .unwrap();
4857        let risks = report.get("risks").and_then(Value::as_array).unwrap();
4858        assert!(
4859            risks
4860                .iter()
4861                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
4862            "expected an unreachable-commits risk: {report}"
4863        );
4864    }
4865
4866    #[tokio::test]
4867    async fn close_self_close_removes_when_the_requester_owns_the_target() {
4868        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4869        let svc = WorktreesService::new();
4870        // The requesting window itself has the worktree open: it is the only
4871        // owning window, so there is nothing to wait on — remove and reply, and
4872        // the extension closes its own window on `ok`.
4873        svc.handle(
4874            "register",
4875            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
4876        )
4877        .await
4878        .unwrap();
4879        let reply = svc
4880            .handle(
4881                "close",
4882                json!({
4883                    "path": wt_path,
4884                    "remove": true,
4885                    "confirmed": true,
4886                    "requester_key": "w1",
4887                }),
4888            )
4889            .await
4890            .unwrap();
4891        assert_eq!(reply, json!({ "removed": true }));
4892        assert!(!wt_path.exists());
4893    }
4894
4895    #[tokio::test]
4896    async fn close_safety_check_surfaces_the_owning_window() {
4897        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4898        let svc = WorktreesService::new();
4899        // A multi-root window owns the target: the report surfaces its key and
4900        // folder count so the extension can warn "all N folders will close".
4901        svc.handle(
4902            "register",
4903            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
4904        )
4905        .await
4906        .unwrap();
4907        let report = svc
4908            .handle("close", json!({ "path": wt_path, "remove": true }))
4909            .await
4910            .unwrap();
4911        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
4912        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
4913        assert_eq!(
4914            report.get("window_folder_count").and_then(Value::as_u64),
4915            Some(2)
4916        );
4917    }
4918
4919    #[tokio::test]
4920    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
4921        let svc = WorktreesService::new();
4922        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
4923            .await
4924            .unwrap();
4925        // No directive → a plain `{ known: true }`, byte-identical to before.
4926        assert_eq!(
4927            svc.handle("heartbeat", json!({ "key": "w1" }))
4928                .await
4929                .unwrap(),
4930            json!({ "known": true })
4931        );
4932        // Marked → the next heartbeat carries `close: true`, exactly once.
4933        svc.registry.mark_close_pending("w1");
4934        assert_eq!(
4935            svc.handle("heartbeat", json!({ "key": "w1" }))
4936                .await
4937                .unwrap(),
4938            json!({ "known": true, "close": true })
4939        );
4940        assert_eq!(
4941            svc.handle("heartbeat", json!({ "key": "w1" }))
4942                .await
4943                .unwrap(),
4944            json!({ "known": true })
4945        );
4946    }
4947
4948    #[tokio::test]
4949    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
4950        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
4951        let svc = Arc::new(WorktreesService::new());
4952        // A *different* window (not the requester) owns the target.
4953        svc.handle(
4954            "register",
4955            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
4956        )
4957        .await
4958        .unwrap();
4959
4960        // Drive the destructive close concurrently: it marks w2 to close and
4961        // waits for it to unregister before removing.
4962        let svc2 = svc.clone();
4963        let path = wt_path.clone();
4964        let close = tokio::spawn(async move {
4965            svc2.handle(
4966                "close",
4967                json!({
4968                    "path": path,
4969                    "remove": true,
4970                    "confirmed": true,
4971                    "requester_key": "w1",
4972                }),
4973            )
4974            .await
4975        });
4976
4977        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
4978        // closes its window and unregisters. Poll until the directive appears.
4979        let mut saw_close = false;
4980        for _ in 0..200 {
4981            let hb = svc
4982                .handle("heartbeat", json!({ "key": "w2" }))
4983                .await
4984                .unwrap();
4985            if hb.get("close").and_then(Value::as_bool) == Some(true) {
4986                saw_close = true;
4987                svc.handle("unregister", json!({ "key": "w2" }))
4988                    .await
4989                    .unwrap();
4990                break;
4991            }
4992            tokio::time::sleep(Duration::from_millis(5)).await;
4993        }
4994        assert!(saw_close, "w2 should have been told to close");
4995
4996        // Once w2 has unregistered, the close op removes the worktree.
4997        let reply = close.await.unwrap().unwrap();
4998        assert_eq!(reply, json!({ "removed": true }));
4999        assert!(!wt_path.exists());
5000    }
5001
5002    #[tokio::test]
5003    async fn await_windows_closed_times_out_when_a_window_never_closes() {
5004        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5005        let svc = WorktreesService::new();
5006        svc.handle(
5007            "register",
5008            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
5009        )
5010        .await
5011        .unwrap();
5012        // The owning window never unregisters: the wait gives up (with a short
5013        // timeout here) rather than block, and names the still-open window.
5014        let err = await_windows_closed(
5015            &svc.registry,
5016            &wt_path,
5017            Some("w1"),
5018            Duration::from_millis(150),
5019            Duration::from_millis(25),
5020        )
5021        .await
5022        .unwrap_err();
5023        assert!(
5024            err.to_string().contains("w2"),
5025            "error names the window: {err}"
5026        );
5027        // The requester itself is excluded, so a self-only owner returns at once.
5028        await_windows_closed(
5029            &svc.registry,
5030            &wt_path,
5031            Some("w2"),
5032            Duration::from_millis(150),
5033            Duration::from_millis(25),
5034        )
5035        .await
5036        .unwrap();
5037    }
5038
5039    #[tokio::test]
5040    async fn close_window_without_remove_replies_closed_and_never_deletes() {
5041        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
5042        let svc = WorktreesService::new();
5043        // "Close Window" on the main tree: no git inspection, no removal.
5044        let reply = svc
5045            .handle("close", json!({ "path": main.path(), "remove": false }))
5046            .await
5047            .unwrap();
5048        assert_eq!(reply, json!({ "closed": true }));
5049        assert!(main.path().exists());
5050    }
5051
5052    #[tokio::test]
5053    async fn close_safety_check_flags_modified_tracked_files() {
5054        // A tracked file, checked out into the linked worktree, then modified —
5055        // its content is lost on removal, so it is a `dirty` risk (distinct from
5056        // the untracked case).
5057        let main_dir = tempfile::tempdir().unwrap();
5058        let repo = init_repo(main_dir.path());
5059        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
5060        repo.set_head("refs/heads/trunk").unwrap();
5061        let wt_parent = tempfile::tempdir().unwrap();
5062        let wt_path = wt_parent.path().join("feature-wt");
5063        add_worktree(&repo, a, &wt_path, "feature");
5064        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
5065
5066        let svc = WorktreesService::new();
5067        let report = svc
5068            .handle("close", json!({ "path": wt_path, "remove": true }))
5069            .await
5070            .unwrap();
5071        let risks = report.get("risks").and_then(Value::as_array).unwrap();
5072        assert!(
5073            risks
5074                .iter()
5075                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
5076            "expected a dirty risk: {report}"
5077        );
5078    }
5079
5080    #[tokio::test]
5081    async fn close_safety_check_flags_an_in_progress_operation() {
5082        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
5083        // non-Clean (interrupted merge) state — its progress is lost on removal.
5084        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5085        let wt_repo = Repository::open(&wt_path).unwrap();
5086        let head = wt_repo.head().unwrap().target().unwrap();
5087        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
5088        assert_ne!(wt_repo.state(), RepositoryState::Clean);
5089
5090        let svc = WorktreesService::new();
5091        let report = svc
5092            .handle("close", json!({ "path": wt_path, "remove": true }))
5093            .await
5094            .unwrap();
5095        let risks = report.get("risks").and_then(Value::as_array).unwrap();
5096        assert!(
5097            risks
5098                .iter()
5099                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
5100            "expected an in-progress risk: {report}"
5101        );
5102    }
5103
5104    #[tokio::test]
5105    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
5106        // A linked worktree on `feature`, which tracks `origin/feature` and is one
5107        // commit ahead. The unpushed commit is INFO (the branch — and thus the
5108        // commit — survives removal), never a blocking risk.
5109        let main_dir = tempfile::tempdir().unwrap();
5110        let repo = init_repo(main_dir.path());
5111        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
5112        repo.set_head("refs/heads/trunk").unwrap();
5113        let a_commit = repo.find_commit(a).unwrap();
5114        repo.branch("feature", &a_commit, false).unwrap();
5115        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
5116            .unwrap();
5117        // `feature` advances one commit past `origin/feature`.
5118        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
5119        drop(a_commit);
5120        let mut cfg = repo.config().unwrap();
5121        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
5122            .unwrap();
5123        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
5124            .unwrap();
5125        cfg.set_str("branch.feature.remote", "origin").unwrap();
5126        cfg.set_str("branch.feature.merge", "refs/heads/feature")
5127            .unwrap();
5128        // A worktree on the existing `feature` branch (not created fresh, so it
5129        // keeps the ahead-of-upstream divergence).
5130        let wt_parent = tempfile::tempdir().unwrap();
5131        let wt_path = wt_parent.path().join("feature-wt");
5132        let reference = repo.find_reference("refs/heads/feature").unwrap();
5133        let mut opts = git2::WorktreeAddOptions::new();
5134        opts.reference(Some(&reference));
5135        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
5136
5137        let svc = WorktreesService::new();
5138        let report = svc
5139            .handle("close", json!({ "path": wt_path, "remove": true }))
5140            .await
5141            .unwrap();
5142        // Unpushed commits appear as `info`, and the worktree is still cleanly
5143        // removable with no blocking risks.
5144        let info = report.get("info").and_then(Value::as_array).unwrap();
5145        assert!(
5146            info.iter()
5147                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
5148            "expected an unpushed info note: {report}"
5149        );
5150        assert!(
5151            report
5152                .get("risks")
5153                .and_then(Value::as_array)
5154                .unwrap()
5155                .is_empty(),
5156            "unpushed commits alone must not block: {report}"
5157        );
5158        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
5159    }
5160
5161    #[tokio::test]
5162    async fn close_safety_check_ignores_gitignored_files() {
5163        // With `.gitignore` committed, an ignored artifact is the only worktree
5164        // change — it must not count as untracked (it is regenerable), so the
5165        // worktree stays cleanly removable with no risks.
5166        let main_dir = tempfile::tempdir().unwrap();
5167        let repo = init_repo(main_dir.path());
5168        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
5169        repo.set_head("refs/heads/trunk").unwrap();
5170        let wt_parent = tempfile::tempdir().unwrap();
5171        let wt_path = wt_parent.path().join("feature-wt");
5172        add_worktree(&repo, a, &wt_path, "feature");
5173        std::fs::create_dir(wt_path.join("build")).unwrap();
5174        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
5175
5176        let svc = WorktreesService::new();
5177        let report = svc
5178            .handle("close", json!({ "path": wt_path, "remove": true }))
5179            .await
5180            .unwrap();
5181        assert!(
5182            report
5183                .get("risks")
5184                .and_then(Value::as_array)
5185                .unwrap()
5186                .is_empty(),
5187            "a gitignored file must not create a risk: {report}"
5188        );
5189        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
5190    }
5191
5192    #[tokio::test]
5193    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
5194        // The phase-1 check on a path that no longer exists reports it removable
5195        // with no risks (so the idempotent execute proceeds with no dialog).
5196        let svc = WorktreesService::new();
5197        let report = svc
5198            .handle(
5199                "close",
5200                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
5201            )
5202            .await
5203            .unwrap();
5204        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
5205        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
5206        assert!(report
5207            .get("risks")
5208            .and_then(Value::as_array)
5209            .unwrap()
5210            .is_empty());
5211        let info = report.get("info").and_then(Value::as_array).unwrap();
5212        assert!(info
5213            .iter()
5214            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
5215    }
5216
5217    #[tokio::test]
5218    async fn close_refuses_a_locked_worktree() {
5219        // A locked worktree (git worktree lock) must be refused, not forced past
5220        // (failure mode #6), and left on disk.
5221        let (main, _wtp, wt_path) = repo_with_linked_worktree();
5222        let main_repo = Repository::open(main.path()).unwrap();
5223        main_repo
5224            .find_worktree("feature")
5225            .unwrap()
5226            .lock(Some("under test"))
5227            .unwrap();
5228
5229        let svc = WorktreesService::new();
5230        let err = svc
5231            .handle(
5232                "close",
5233                json!({ "path": wt_path, "remove": true, "confirmed": true }),
5234            )
5235            .await
5236            .unwrap_err();
5237        assert!(
5238            err.to_string().contains("locked"),
5239            "expected a locked error: {err}"
5240        );
5241        assert!(wt_path.exists(), "a locked worktree must not be removed");
5242    }
5243
5244    #[tokio::test]
5245    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
5246        // A detached HEAD that still sits on a commit a branch points to loses
5247        // nothing on removal, so it must NOT produce an unreachable-commits risk
5248        // (the false-positive the reachability walk guards against).
5249        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5250        let wt_repo = Repository::open(&wt_path).unwrap();
5251        // The worktree is on `feature`; detach HEAD onto its current tip, which
5252        // the `feature` branch still references.
5253        let tip = wt_repo.head().unwrap().target().unwrap();
5254        wt_repo.set_head_detached(tip).unwrap();
5255        assert!(wt_repo.head_detached().unwrap());
5256
5257        let svc = WorktreesService::new();
5258        let report = svc
5259            .handle("close", json!({ "path": wt_path, "remove": true }))
5260            .await
5261            .unwrap();
5262        let risks = report.get("risks").and_then(Value::as_array).unwrap();
5263        assert!(
5264            !risks
5265                .iter()
5266                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
5267            "a detached HEAD reachable from a branch must not be flagged: {report}"
5268        );
5269    }
5270
5271    #[test]
5272    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
5273        let (main, _wtp, wt_path) = repo_with_linked_worktree();
5274        let main_repo = Repository::open(main.path()).unwrap();
5275        // The real linked worktree resolves to its registered name.
5276        assert_eq!(
5277            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
5278            "feature"
5279        );
5280        // A path that is not one of this repo's worktrees is the defensive
5281        // "not registered" error (the guard behind removal).
5282        let err =
5283            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
5284        assert!(
5285            err.to_string().contains("not registered"),
5286            "expected a not-registered error: {err}"
5287        );
5288    }
5289
5290    #[test]
5291    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
5292        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
5293        // rather than sinking the whole safety check.
5294        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
5295        let repo = Repository::open(&wt_path).unwrap();
5296        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
5297        // Confirm the corruption actually breaks status enumeration, so the
5298        // count is exercising the error-degradation branch (not an empty repo).
5299        assert!(
5300            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
5301            "a corrupt index should make statuses() fail"
5302        );
5303        assert_eq!(count_dirty_untracked(&repo), (0, 0));
5304    }
5305}