Skip to main content

omni_dev/
worktrees.rs

1//! The cross-window worktree registry engine.
2//!
3//! Maintains the live, authoritative set of repos/worktrees open across *every*
4//! VS Code window, fed by a first-party companion extension that reports from
5//! each window over the daemon's control socket. The resident daemon is the
6//! rendezvous point the per-window extension sandbox cannot replace: each window
7//! can see only its own `workspace.workspaceFolders`, so a single process
8//! aggregating those registrations is the only cross-window source of truth.
9//! See ADR-0040.
10//!
11//! This is the standalone engine, analogous to [`crate::browser`] and
12//! [`crate::snowflake`]; the daemon adapter lives in
13//! [`crate::daemon::services::worktrees`].
14//!
15//! Like the Snowflake engine this is cheap and in-memory — no async setup, no
16//! secret persisted. The registry lives behind a [`std::sync::Mutex`] that is
17//! **never held across an `.await`** (the Snowflake rule); every op is pure CPU
18//! under the lock, so liveness reaping happens inline on each read rather than
19//! from a background task.
20
21use std::collections::{HashMap, HashSet};
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Mutex, MutexGuard, PoisonError};
25use std::time::Duration;
26
27use chrono::{DateTime, Duration as ChronoDuration, Utc};
28use serde::{Deserialize, Serialize};
29use tokio::sync::watch;
30
31/// How long a window may go silent before it ages out of the registry. Three
32/// missed ~10s heartbeats; a window that crashed without firing `unregister`
33/// disappears on the next read. The resident process is what makes this
34/// liveness correct — a flat shared file could not reap stale entries.
35const DEFAULT_TTL: Duration = Duration::from_secs(30);
36
37/// How long a per-repository PR-poll lease lasts before it auto-expires (#1376).
38/// Enabling polling for a repo is deliberately **temporary** — 15 minutes — so an
39/// idle repo stops costing GitHub budget without the user remembering to disable
40/// it; re-enabling refreshes the lease.
41const DEFAULT_POLL_LEASE: Duration = Duration::from_secs(15 * 60);
42
43/// Ceiling on live registry entries, so a misbehaving companion flooding
44/// `register` with distinct keys cannot grow daemon memory faster than the TTL
45/// reaps it (#1140). Far above any real window count; when a new key would
46/// exceed it, the longest-silent entry is evicted instead of rejecting the
47/// request — an evicted live window self-heals via the `heartbeat` →
48/// `{known: false}` → re-register path, so `register` stays infallible for the
49/// companion.
50const MAX_WINDOWS: usize = 256;
51
52/// A `register` request from a companion extension.
53///
54/// The companion owns its `key` (a per-`activate()` UUID) so the registry never
55/// has to reason about whether `vscode.env.sessionId` is unique per window;
56/// everything else is best-effort metadata.
57#[derive(Debug, Clone, Deserialize)]
58pub struct RegisterRequest {
59    /// Stable per-window identity, generated by the companion on activation.
60    pub key: String,
61    /// Absolute paths of the window's workspace folders.
62    #[serde(default)]
63    pub folders: Vec<PathBuf>,
64    /// Repository root or name, when the window has one.
65    #[serde(default)]
66    pub repo: Option<String>,
67    /// The window title, for display.
68    #[serde(default)]
69    pub title: Option<String>,
70    /// The reporting extension-host process id.
71    #[serde(default)]
72    pub pid: Option<u32>,
73}
74
75/// One open window's live registration. Serialized verbatim into `list` /
76/// `status` payloads; consumers compute age from `last_seen` (RFC 3339).
77#[derive(Debug, Clone, Serialize)]
78pub struct WindowEntry {
79    /// The companion-owned per-window key.
80    pub key: String,
81    /// Absolute workspace-folder paths.
82    pub folders: Vec<PathBuf>,
83    /// Repository root or name, if reported.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub repo: Option<String>,
86    /// Window title, if reported.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub title: Option<String>,
89    /// Reporting extension-host pid, if reported.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub pid: Option<u32>,
92    /// When the registry last heard from this window (register or heartbeat).
93    pub last_seen: DateTime<Utc>,
94}
95
96/// The cross-window worktree registry: the in-memory, TTL-reaped set of open
97/// windows. Hosted by
98/// [`WorktreesService`](crate::daemon::services::worktrees::WorktreesService).
99pub struct WorktreesRegistry {
100    /// Open windows keyed by their companion-owned `key`.
101    windows: Mutex<HashMap<String, WindowEntry>>,
102    /// How long an entry survives without a heartbeat.
103    ttl: Duration,
104    /// A monotonically-bumped version counter, incremented whenever the visible
105    /// set of windows changes (a `register`, a removing `unregister`, or a
106    /// mutation-driven reap that drops a stale entry). A push-subscription
107    /// consumer holds a [`watch::Receiver`] from
108    /// [`subscribe_changes`](Self::subscribe_changes) and wakes on each bump to
109    /// re-snapshot (#1267). The counter's *value* is immaterial —
110    /// only that it changed — so a burst coalesces into one wake and the
111    /// subscriber diffs the resulting snapshot to suppress duplicate frames.
112    ///
113    /// `watch` needs no runtime and never blocks, so it fits this engine's
114    /// no-async-setup posture; every [`bump`](Self::bump) happens *after* the map
115    /// guard is dropped, so the `std::Mutex`-never-across-`.await` rule is intact
116    /// (and the watch's own internal lock is never nested under the map lock).
117    changes: watch::Sender<u64>,
118    /// Window keys with a pending "close yourself" directive, set by the
119    /// `close` op (#1277) when a cross-window close must reach a window the
120    /// daemon can only *reply* to, never call. Each key is surfaced — and
121    /// cleared — on that window's next `heartbeat` (the `known:false →
122    /// re-register` precedent, riding the same reply). In-memory only, like the
123    /// window map: a daemon restart drops any pending directive (the close op
124    /// aborts and the user retries — an accepted failure mode). Behind its own
125    /// `Mutex`, taken independently of the window map's, so neither nests.
126    close_pending: Mutex<HashSet<String>>,
127    /// Window keys with a pending "reload yourself" directive, set by the
128    /// `reload` op (#1417). The second directive of the
129    /// [`close_pending`](Self::close_pending) shape, and for the same reason: a
130    /// window can only be reached on the `heartbeat` it initiates, so a
131    /// cross-window reload rides that reply and is taken-and-cleared to fire
132    /// exactly once. Unlike a close, nothing waits for it — a reload has no
133    /// completion the daemon can observe (the window re-registers under the same
134    /// key), so the op reports what it *signalled*, never what reloaded.
135    /// In-memory only: a daemon restart drops any pending directive, and the
136    /// user simply reloads again. Behind its **own** `Mutex`, taken
137    /// independently of the window map's and of `close_pending`'s, so none nest.
138    reload_pending: Mutex<HashSet<String>>,
139    /// Worktree paths the daemon is **currently rebasing** (#1415) — the
140    /// transient half of the tree view's rebase cue.
141    ///
142    /// Two orthogonal facts drive that cue and neither substitutes for the other.
143    /// The *durable* one is the worktree's own `repo.state()`, read fresh off disk
144    /// into each snapshot: it survives a daemon restart and keeps showing a
145    /// left-in-place conflict until the user resolves it. This is the *transient*
146    /// one: a rebase that is running right now has not yet written a
147    /// `.git/rebase-merge` state the snapshot can see for most of its life, and a
148    /// clean rebase never leaves one at all — so without this a multi-second
149    /// rebase would render as nothing happening.
150    ///
151    /// Unlike the two directives above this is **consumer-visible state**, not a
152    /// message to one window: it rides the `tree` snapshot, so marking and
153    /// clearing it bumps the change-notify (a directive never does).
154    ///
155    /// Keyed by **canonicalized worktree path**, not window key: a rebase targets
156    /// worktrees, and a worktree need not have a window open on it. Behind its own
157    /// `Mutex`, taken independently of the window map's, `close_pending`'s and
158    /// `reload_pending`'s, so none of the four ever nest. In-memory only — a
159    /// daemon restart clears it, which is correct: nothing is rebasing any more,
160    /// and the durable `operation` field still shows any conflict left behind.
161    rebasing: Mutex<HashSet<PathBuf>>,
162    /// Worktree paths the daemon is **currently pushing** (#1443) — the twin of
163    /// [`rebasing`](Self::rebasing), with the same keying, locking and
164    /// bump-only-on-a-real-change contract.
165    ///
166    /// Unlike a rebase this cue has **no durable half**: a push writes no on-disk
167    /// state for a later snapshot to rediscover, so there is no `operation`
168    /// equivalent and this set is the whole of it. What makes a *completed* push
169    /// visible instead is `upstream_sha` moving on the snapshot — which is exactly
170    /// why that field is carried (#1344).
171    pushing: Mutex<HashSet<PathBuf>>,
172    /// The daemon-backed **show/hide-closed** toggle (#1301): whether the
173    /// companion's tree view shows worktrees with no open window. A single
174    /// cross-window value carried in every `tree`/`subscribe` snapshot so all
175    /// windows read (and live-sync) the same state — `context.globalState` could
176    /// not, being read-once with no cross-window change event. Defaults to `true`
177    /// (show all, the original behavior). A lock-free [`AtomicBool`] rather than a
178    /// `Mutex`, so it is never a `.await`-holding-a-lock hazard; a flip
179    /// [`bump`](Self::bump)s the change-notify so subscribers re-push. In-memory
180    /// like the window map: a daemon restart resets it to the default, which the
181    /// next snapshot propagates to every window.
182    show_closed: AtomicBool,
183    /// The **per-repository PR-poll** enable set (#1376): the GitHub repos
184    /// (`"owner/name"`) whose PR badges the daemon polls. Polling defaults
185    /// **off** — a repo not in this set issues zero `gh` — so the user enables
186    /// only the handful of repos they are actively working on, rather than the
187    /// daemon polling all 29 open repos and exhausting the GitHub budget. A
188    /// cross-window value like [`show_closed`](Self::show_closed): the daemon
189    /// stamps each repo's state onto the `tree` snapshot (`polling_enabled`) and
190    /// a [`set_polling`](Self::set_polling) flip [`bump`](Self::bump)s the
191    /// change-notify so every window recolors and drops/keeps badges in sync.
192    ///
193    /// Unlike `show_closed` this survives a daemon restart: the adapter seeds it
194    /// from a `0600` file on startup ([`seed_polling`](Self::seed_polling)) and
195    /// persists it on each change — otherwise a restart would silently re-disable
196    /// every repo. Behind its **own** `Mutex`, taken independently of the window
197    /// map's (neither nests) and never held across an `.await`.
198    ///
199    /// Each enable is a **time-boxed lease**, not a permanent flag (#1376): the
200    /// value is the wall-clock instant the lease **expires** ([`poll_ttl`] after
201    /// it was enabled), so an idle repo auto-disables and stops costing `gh`
202    /// without the user remembering to turn it back off. Expired entries are
203    /// reaped on read — the window-TTL precedent — so the icon greys, badges
204    /// drop, and the poller stops within one snapshot tick of expiry.
205    ///
206    /// [`poll_ttl`]: Self::poll_ttl
207    polling_enabled: Mutex<HashMap<String, DateTime<Utc>>>,
208    /// How long a repo's PR-poll lease lasts before it auto-expires (#1376).
209    /// [`DEFAULT_POLL_LEASE`] in production; tests inject a short value via the
210    /// `#[cfg(test)]` `with_poll_ttl` constructor (not linked — it does not exist
211    /// in a non-test doc build).
212    poll_ttl: Duration,
213}
214
215impl WorktreesRegistry {
216    /// Creates the registry with the default liveness TTL. Cheap — no I/O.
217    #[must_use]
218    pub fn new() -> Self {
219        Self {
220            windows: Mutex::new(HashMap::new()),
221            ttl: DEFAULT_TTL,
222            changes: watch::channel(0).0,
223            close_pending: Mutex::new(HashSet::new()),
224            reload_pending: Mutex::new(HashSet::new()),
225            rebasing: Mutex::new(HashSet::new()),
226            pushing: Mutex::new(HashSet::new()),
227            show_closed: AtomicBool::new(true),
228            polling_enabled: Mutex::new(HashMap::new()),
229            poll_ttl: DEFAULT_POLL_LEASE,
230        }
231    }
232
233    /// Creates a registry with a custom PR-poll lease duration, for tests that
234    /// exercise auto-expiry without waiting the full 15 minutes.
235    #[cfg(test)]
236    #[must_use]
237    pub fn with_poll_ttl(poll_ttl: Duration) -> Self {
238        Self {
239            poll_ttl,
240            ..Self::new()
241        }
242    }
243
244    /// A change-notification receiver for the push subscription: it observes a
245    /// new value each time the visible window set changes (see [`changes`] and
246    /// [`bump`]). Created with the current version already marked seen, so the
247    /// first [`watch::Receiver::changed`] resolves on the *next* change — the
248    /// subscriber sends its own initial snapshot up front and then waits for
249    /// deltas (#1267).
250    ///
251    /// [`changes`]: Self::changes
252    /// [`bump`]: Self::bump
253    #[must_use]
254    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
255        self.changes.subscribe()
256    }
257
258    /// Signals subscribers that the visible state changed. Non-blocking and
259    /// runtime-free; called only *after* the map guard is released so the two
260    /// locks never nest. A send never fails here (the sender is owned by the
261    /// registry, which outlives every receiver, and `send_modify` bumps even
262    /// with no receivers).
263    ///
264    /// Visible outside the registry so the daemon's PR badge poller can signal a
265    /// changed CI verdict (#1337) — the tree snapshot carries more than the window
266    /// set. Callers must bump **only on a real change**: an unconditional bump
267    /// defeats the server's snapshot diff and re-pushes to every window on every
268    /// tick.
269    pub(crate) fn bump(&self) {
270        self.changes.send_modify(|v| *v = v.wrapping_add(1));
271    }
272
273    /// The current change-notify generation — the counter [`bump`](Self::bump)
274    /// increments on every visible-set change. Read (never subscribed) so a
275    /// coalescing consumer — the service's shared tree-snapshot cache (#1303) —
276    /// can tell whether the registry has changed since it last computed and
277    /// rebuild only then. The value itself is immaterial; only whether it
278    /// differs between two reads matters, so `wrapping_add` overflow is benign.
279    #[must_use]
280    pub fn change_generation(&self) -> u64 {
281        *self.changes.borrow()
282    }
283
284    /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
285    /// critical section must not wedge the whole registry).
286    fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
287        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
288    }
289
290    /// Records (upserts) a window registration. Reaps stale entries first, then
291    /// — only when a genuinely new key would grow the map past [`MAX_WINDOWS`] —
292    /// evicts the longest-silent entry. Infallible: an upsert never evicts, and
293    /// callers validate the `key` before reaching here.
294    pub fn register(&self, req: RegisterRequest) {
295        let now = Utc::now();
296        {
297            let mut windows = self.lock();
298            reap(&mut windows, self.ttl, now);
299            // Upserts never evict; only a genuinely new key can grow the map, and
300            // never past MAX_WINDOWS.
301            if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
302                evict_oldest(&mut windows);
303            }
304            windows.insert(
305                req.key.clone(),
306                WindowEntry {
307                    key: req.key,
308                    folders: req.folders,
309                    repo: req.repo,
310                    title: req.title,
311                    pid: req.pid,
312                    last_seen: now,
313                },
314            );
315        }
316        // Always bump: a register is infrequent (once per companion `activate()`,
317        // not per heartbeat) and may add or alter a window's folders/repo. A
318        // no-op re-register with identical data is harmless — the subscriber
319        // diffs the snapshot and suppresses the duplicate frame.
320        self.bump();
321    }
322
323    /// Pauses the liveness clock for `outage`: advances every live entry's
324    /// `last_seen` by that long, never past now.
325    ///
326    /// For a stretch when the daemon could not hear its windows — its `accept`
327    /// loop was failing (#2111) — so their silence says nothing about whether they
328    /// are alive. Without this the first heartbeat after an outage reaps every
329    /// *other* window that has been quiet more than the TTL, before it has had a
330    /// chance to send its own, and whole repos vanish from the tree for a few
331    /// seconds. Only entries still in the map are credited: an entry that was
332    /// already reaped stays gone, and one that was already stale before the outage
333    /// began is credited no more than its neighbours. Not a visible change, so it
334    /// does not bump the change-notify.
335    pub fn credit_outage(&self, outage: Duration) {
336        let Ok(credit) = ChronoDuration::from_std(outage) else {
337            return;
338        };
339        let now = Utc::now();
340        for entry in self.lock().values_mut() {
341            // Saturate rather than overflow on an absurd credit.
342            entry.last_seen = entry
343                .last_seen
344                .checked_add_signed(credit)
345                .map_or(now, |credited| credited.min(now));
346        }
347    }
348
349    /// Refreshes a window's liveness. Returns whether the key was known: a
350    /// `false` tells a window that started before the daemon — or survived a
351    /// daemon restart — to re-`register`, since the registry is in-memory and
352    /// has no record of it.
353    pub fn heartbeat(&self, key: &str) -> bool {
354        let now = Utc::now();
355        let (known, reaped) = {
356            let mut windows = self.lock();
357            let reaped = reap(&mut windows, self.ttl, now);
358            let known = match windows.get_mut(key) {
359                Some(entry) => {
360                    entry.last_seen = now;
361                    true
362                }
363                None => false,
364            };
365            (known, reaped)
366        };
367        // A heartbeat is frequent (~every 10 s per window); a pure liveness
368        // refresh does not change the visible set, so bump *only* when this
369        // heartbeat's inline reap actually aged a stale sibling out.
370        if reaped > 0 {
371            self.bump();
372        }
373        known
374    }
375
376    /// Drops a window's registration. Returns whether an entry was present.
377    pub fn unregister(&self, key: &str) -> bool {
378        let now = Utc::now();
379        let (removed, reaped) = {
380            let mut windows = self.lock();
381            let removed = windows.remove(key).is_some();
382            let reaped = reap(&mut windows, self.ttl, now);
383            (removed, reaped)
384        };
385        // The window is gone; any directive for it is fulfilled or moot.
386        // (Keys are per-`activate()` UUIDs, never reused, so a stale directive
387        // would only ever leak a little memory — but clearing keeps it tidy.)
388        // Both takes are outside the map's critical section, so no lock nests.
389        self.take_close_pending(key);
390        self.take_reload_pending(key);
391        if removed || reaped > 0 {
392            self.bump();
393        }
394        removed
395    }
396
397    /// Records a pending "close yourself" directive for `key`, to be surfaced on
398    /// that window's next `heartbeat`. Set by the `close` op when signalling a
399    /// window it can only reply to. Idempotent; infallible.
400    pub fn mark_close_pending(&self, key: &str) {
401        self.close_pending
402            .lock()
403            .unwrap_or_else(PoisonError::into_inner)
404            .insert(key.to_string());
405    }
406
407    /// Takes (returns and clears) `key`'s pending close directive. Called on
408    /// each `heartbeat` so the directive fires exactly once; a `false` means no
409    /// close is pending.
410    pub fn take_close_pending(&self, key: &str) -> bool {
411        self.close_pending
412            .lock()
413            .unwrap_or_else(PoisonError::into_inner)
414            .remove(key)
415    }
416
417    /// Records a pending "reload yourself" directive for `key`, to be surfaced
418    /// on that window's next `heartbeat` (#1417). Set by the `reload` op for
419    /// every target window, which — unlike a close — includes no waiting: the
420    /// caller learns only that the directive was marked. Idempotent; infallible.
421    pub fn mark_reload_pending(&self, key: &str) {
422        self.reload_pending
423            .lock()
424            .unwrap_or_else(PoisonError::into_inner)
425            .insert(key.to_string());
426    }
427
428    /// Takes (returns and clears) `key`'s pending reload directive. Called on
429    /// each `heartbeat` so the directive fires exactly once; a `false` means no
430    /// reload is pending.
431    pub fn take_reload_pending(&self, key: &str) -> bool {
432        self.reload_pending
433            .lock()
434            .unwrap_or_else(PoisonError::into_inner)
435            .remove(key)
436    }
437
438    /// Marks `paths` as being rebased right now (#1415), returning whether the
439    /// set actually changed.
440    ///
441    /// A real change [`bump`](Self::bump)s the change-notify so every subscribed
442    /// window re-pushes a snapshot carrying the spinner — the same cross-window
443    /// sync `set_show_closed` / `set_polling` rely on. Bumping **only** on a real
444    /// change is load-bearing rather than an optimization: an unconditional bump
445    /// defeats the server's snapshot diff and re-pushes to every window on every
446    /// tick.
447    ///
448    /// Callers pass **already-canonicalized** paths, so these match the tree
449    /// snapshot's own keys. Canonicalizing is disk I/O, which belongs in the
450    /// adapter (where `canonical()` lives), not in this engine — the same split
451    /// that keeps the git enrichment out of here.
452    pub fn mark_rebasing(&self, paths: &[PathBuf]) -> bool {
453        self.mutate_in_flight(&self.rebasing, paths, true)
454    }
455
456    /// Clears the rebasing mark on `paths`, returning whether the set changed.
457    /// Called on **every** exit from a phase-2 execute, so a panicking or failing
458    /// rebase can never leave a permanent spinner on a row.
459    pub fn clear_rebasing(&self, paths: &[PathBuf]) -> bool {
460        self.mutate_in_flight(&self.rebasing, paths, false)
461    }
462
463    /// Marks `paths` as being pushed right now (#1443), returning whether the set
464    /// actually changed. The [`mark_rebasing`](Self::mark_rebasing) twin, with the
465    /// same canonicalized-paths-in and bump-only-on-a-real-change contract.
466    pub fn mark_pushing(&self, paths: &[PathBuf]) -> bool {
467        self.mutate_in_flight(&self.pushing, paths, true)
468    }
469
470    /// Clears the pushing mark on `paths`, returning whether the set changed.
471    /// Called on **every** exit from a phase-2 execute — and it is the *only* thing
472    /// that clears this cue, since a push leaves no on-disk state a later snapshot
473    /// could correct a stale mark from.
474    pub fn clear_pushing(&self, paths: &[PathBuf]) -> bool {
475        self.mutate_in_flight(&self.pushing, paths, false)
476    }
477
478    /// The shared body of the in-flight-set mutators: mutate under that set's own
479    /// lock, drop the guard, then bump if anything moved (never bump while holding
480    /// a lock, per the engine's `std::Mutex`-never-across-`.await` discipline).
481    ///
482    /// Bumping **only** on a real change is load-bearing rather than an
483    /// optimization: an unconditional bump defeats the server's snapshot diff and
484    /// re-pushes to every window on every tick.
485    fn mutate_in_flight(
486        &self,
487        set: &Mutex<HashSet<PathBuf>>,
488        paths: &[PathBuf],
489        insert: bool,
490    ) -> bool {
491        let changed = {
492            let mut set = set.lock().unwrap_or_else(PoisonError::into_inner);
493            paths.iter().fold(false, |changed, path| {
494                let moved = if insert {
495                    set.insert(path.clone())
496                } else {
497                    set.remove(path)
498                };
499                changed || moved
500            })
501        };
502        if changed {
503            self.bump();
504        }
505        changed
506    }
507
508    /// The worktree paths currently being rebased, canonicalized. Read into each
509    /// `tree`/`subscribe` snapshot; cheap (a set clone of at most a batch's worth
510    /// of paths) and never held across an `.await`.
511    #[must_use]
512    pub fn rebasing_paths(&self) -> HashSet<PathBuf> {
513        Self::snapshot_paths(&self.rebasing)
514    }
515
516    /// The worktree paths currently being pushed, canonicalized (#1443). The
517    /// [`rebasing_paths`](Self::rebasing_paths) twin, read into the same snapshot.
518    #[must_use]
519    pub fn pushing_paths(&self) -> HashSet<PathBuf> {
520        Self::snapshot_paths(&self.pushing)
521    }
522
523    /// A clone of one in-flight set, for folding into a tree snapshot.
524    fn snapshot_paths(set: &Mutex<HashSet<PathBuf>>) -> HashSet<PathBuf> {
525        set.lock().unwrap_or_else(PoisonError::into_inner).clone()
526    }
527
528    /// The current show/hide-closed toggle: whether the tree view shows
529    /// worktrees with no open window (#1301). Read into every `tree`/`subscribe`
530    /// snapshot so every window renders the same, live-synced state.
531    #[must_use]
532    pub fn show_closed(&self) -> bool {
533        self.show_closed.load(Ordering::Relaxed)
534    }
535
536    /// Sets the show/hide-closed toggle, returning whether the value actually
537    /// changed. A real change [`bump`](Self::bump)s the change-notify so every
538    /// subscriber re-pushes a snapshot carrying the new value — the reliable
539    /// cross-window sync `context.globalState` could not provide. A no-op set
540    /// (same value) neither bumps nor wakes anyone.
541    pub fn set_show_closed(&self, show_closed: bool) -> bool {
542        let changed = self.show_closed.swap(show_closed, Ordering::Relaxed) != show_closed;
543        if changed {
544            self.bump();
545        }
546        changed
547    }
548
549    /// Locks the per-repo PR-poll lease map (`"owner/name"` → lease-expiry
550    /// instant), recovering from a poisoned mutex (a panic in a prior critical
551    /// section must not wedge polling for the whole daemon).
552    fn polling_lock(&self) -> MutexGuard<'_, HashMap<String, DateTime<Utc>>> {
553        self.polling_enabled
554            .lock()
555            .unwrap_or_else(PoisonError::into_inner)
556    }
557
558    /// Whether PR polling is currently leased for the GitHub repo `owner/name`
559    /// (#1376) — the entry exists **and** its lease has not expired. Defaults
560    /// **false** (a never-toggled repo does not poll), so only repos the user has
561    /// explicitly enabled, within the last [`poll_ttl`](Self::poll_ttl), poll.
562    #[must_use]
563    pub fn is_polling_enabled(&self, owner: &str, name: &str) -> bool {
564        let now = Utc::now();
565        self.polling_lock()
566            .get(&polling_key(owner, name))
567            .is_some_and(|expiry| *expiry > now)
568    }
569
570    /// The repos with a **live** (unexpired) lease, as a set of `"owner/name"`
571    /// keys — what stamps `polling_enabled` onto the `tree` snapshot. Reaps
572    /// expired leases first (the window-TTL reap-on-read precedent), so an idle
573    /// repo drops out on the next snapshot build without a background timer.
574    /// Cloned out so the lock is never held across the (blocking-thread) tree
575    /// build that reads it.
576    #[must_use]
577    pub fn enabled_polling_repos(&self) -> HashSet<String> {
578        let now = Utc::now();
579        let mut map = self.polling_lock();
580        map.retain(|_, expiry| *expiry > now);
581        map.keys().cloned().collect()
582    }
583
584    /// The live leases as `(repo, expiry)` pairs sorted by repo, for
585    /// deterministic persistence to the `0600` prefs file (#1376) — the expiry is
586    /// stored so a daemon restart within the lease window keeps the *remaining*
587    /// time rather than resetting the clock. Reaps expired leases first, so a
588    /// stale entry is never written back.
589    #[must_use]
590    pub fn polling_snapshot(&self) -> Vec<(String, DateTime<Utc>)> {
591        let now = Utc::now();
592        let mut map = self.polling_lock();
593        map.retain(|_, expiry| *expiry > now);
594        let mut entries: Vec<(String, DateTime<Utc>)> =
595            map.iter().map(|(k, v)| (k.clone(), *v)).collect();
596        entries.sort_by(|a, b| a.0.cmp(&b.0));
597        entries
598    }
599
600    /// Enables (leases for [`poll_ttl`](Self::poll_ttl)) or disables PR polling
601    /// for `owner/name`, returning whether the stored map changed — which the
602    /// adapter uses to decide whether to persist. Enabling an already-leased repo
603    /// **refreshes** the lease (a new expiry), which is a change worth persisting.
604    ///
605    /// [`bump`](Self::bump)s the change-notify only when the repo's **effective**
606    /// enabled state flips (off→on or on→off), so every subscribed window
607    /// re-pushes a snapshot that recolours the icon and drops/keeps badges — the
608    /// [`set_show_closed`](Self::set_show_closed) precedent. A lease *refresh*
609    /// (already on, still on) changes the expiry but not the visible state, so it
610    /// persists without waking anyone.
611    pub fn set_polling(&self, owner: &str, name: &str, enabled: bool) -> bool {
612        let key = polling_key(owner, name);
613        let now = Utc::now();
614        let (changed, flipped) = {
615            let mut map = self.polling_lock();
616            let was_enabled = map.get(&key).is_some_and(|expiry| *expiry > now);
617            if enabled {
618                let expiry = now
619                    + ChronoDuration::from_std(self.poll_ttl).unwrap_or_else(|_| {
620                        ChronoDuration::seconds(DEFAULT_POLL_LEASE.as_secs() as i64)
621                    });
622                let changed = map.insert(key, expiry) != Some(expiry);
623                (changed, !was_enabled)
624            } else {
625                let removed = map.remove(&key).is_some();
626                (removed, was_enabled)
627            }
628        };
629        if flipped {
630            self.bump();
631        }
632        changed
633    }
634
635    /// Replaces the lease map wholesale from the persisted `0600` prefs file
636    /// (#1376), dropping any lease that already expired while the daemon was down.
637    /// Does **not** [`bump`](Self::bump): it runs before any window subscribes, so
638    /// there is no one to notify, and each window's first snapshot already
639    /// reflects the seeded leases.
640    pub fn seed_polling(&self, leases: impl IntoIterator<Item = (String, DateTime<Utc>)>) {
641        let now = Utc::now();
642        *self.polling_lock() = leases
643            .into_iter()
644            .filter(|(_, expiry)| *expiry > now)
645            .collect();
646    }
647
648    /// Test-only: forces `owner/name`'s lease to `expiry`, so a test can simulate
649    /// an elapsed lease deterministically without sleeping for [`poll_ttl`].
650    #[cfg(test)]
651    pub fn set_polling_expiry(&self, owner: &str, name: &str, expiry: DateTime<Utc>) {
652        self.polling_lock().insert(polling_key(owner, name), expiry);
653    }
654
655    /// Reaps stale entries, then returns the live set sorted for deterministic
656    /// output. Holds the lock only for pure-CPU work.
657    ///
658    /// Like the other reads ([`open_folders`](Self::open_folders),
659    /// [`first_folder`](Self::first_folder)) this reaps but never
660    /// [`bump`](Self::bump)s: the only observer of a read-path reap is the push
661    /// subscription's own re-snapshot (or `status`/`menu`), and the
662    /// subscription's periodic tick already re-samples read-only staleness — so
663    /// bumping here would only make the subscription wake itself (#1267).
664    pub fn list(&self) -> Vec<WindowEntry> {
665        let now = Utc::now();
666        let mut windows = self.lock();
667        reap(&mut windows, self.ttl, now);
668        sorted_entries(&windows)
669    }
670
671    /// The first workspace folder of a still-live window, if it has one. Used by
672    /// the tray "focus" action to resolve a key to a folder to open. Does not
673    /// reap — a menu action races the reaper either way, and the caller handles
674    /// a `None` (the window may have closed).
675    pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
676        let windows = self.lock();
677        windows.get(key).and_then(|e| e.folders.first().cloned())
678    }
679
680    /// Snapshots the distinct workspace folders across all live windows — the
681    /// seed set the adapter resolves to repositories (each folder → its git
682    /// common dir → repo root) to enumerate every worktree per repo (#1265).
683    ///
684    /// Reaps stale entries first, then returns the folders sorted and
685    /// deduplicated. Like [`list`](Self::list) it is pure CPU under the lock:
686    /// the git resolution the "distinct repos" derivation needs is disk I/O and
687    /// stays in the adapter, off the registry lock, honouring the
688    /// `Mutex`-never-across-`.await` invariant.
689    pub fn open_folders(&self) -> Vec<PathBuf> {
690        let now = Utc::now();
691        let mut windows = self.lock();
692        reap(&mut windows, self.ttl, now);
693        let mut folders: Vec<PathBuf> = windows
694            .values()
695            .flat_map(|e| e.folders.iter().cloned())
696            .collect();
697        folders.sort();
698        folders.dedup();
699        folders
700    }
701}
702
703impl Default for WorktreesRegistry {
704    fn default() -> Self {
705        Self::new()
706    }
707}
708
709/// Removes entries last seen longer than `ttl` ago, returning how many were
710/// dropped. Pure CPU; the caller holds the registry lock but never `.await`s
711/// while holding it. The count lets a *mutation* path
712/// ([`register`](WorktreesRegistry::register) et al.) decide whether to
713/// [`bump`](WorktreesRegistry::bump) the change-notify; read paths ignore it (see
714/// [`list`](WorktreesRegistry::list)).
715fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) -> usize {
716    let max_age = ttl.as_secs() as i64;
717    let before = windows.len();
718    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
719    before - windows.len()
720}
721
722/// Removes the entry with the oldest `last_seen` (ties broken by lowest key
723/// for determinism). Called when a `register` of a new key would grow the
724/// registry past [`MAX_WINDOWS`]. Pure CPU under the registry lock, like
725/// [`reap`].
726fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
727    let oldest = windows
728        .values()
729        .min_by(|a, b| {
730            a.last_seen
731                .cmp(&b.last_seen)
732                .then_with(|| a.key.cmp(&b.key))
733        })
734        .map(|e| e.key.clone());
735    if let Some(key) = oldest {
736        windows.remove(&key);
737    }
738}
739
740/// The canonical key for a GitHub repo in the per-repo PR-poll set: `owner/name`
741/// (#1376). One place so the registry's set, the snapshot stamp, and the poller
742/// filter all agree on the exact string.
743fn polling_key(owner: &str, name: &str) -> String {
744    format!("{owner}/{name}")
745}
746
747/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
748/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
749fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
750    let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
751    entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
752    entries
753}
754
755#[cfg(test)]
756#[allow(clippy::unwrap_used, clippy::expect_used)]
757mod tests {
758    use super::*;
759
760    fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
761        RegisterRequest {
762            key: key.to_string(),
763            folders: vec![PathBuf::from(folder)],
764            repo: repo.map(str::to_string),
765            title: Some(format!("{key}-title")),
766            pid: Some(1234),
767        }
768    }
769
770    #[test]
771    fn list_is_empty_initially() {
772        let reg = WorktreesRegistry::new();
773        assert!(reg.list().is_empty());
774    }
775
776    #[test]
777    fn register_then_list_round_trips() {
778        let reg = WorktreesRegistry::new();
779        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
780        let windows = reg.list();
781        assert_eq!(windows.len(), 1);
782        assert_eq!(windows[0].key, "w1");
783        assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
784    }
785
786    #[test]
787    fn register_is_idempotent_upsert() {
788        let reg = WorktreesRegistry::new();
789        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
790        // Re-registering the same key updates rather than duplicates.
791        reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
792        let windows = reg.list();
793        assert_eq!(windows.len(), 1);
794        assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
795    }
796
797    #[test]
798    fn heartbeat_reports_known_and_unknown() {
799        let reg = WorktreesRegistry::new();
800        // Unknown before registration: the window must re-register.
801        assert!(!reg.heartbeat("w1"));
802        reg.register(register_request("w1", None, "/tmp/a"));
803        assert!(reg.heartbeat("w1"));
804    }
805
806    #[test]
807    fn unregister_removes() {
808        let reg = WorktreesRegistry::new();
809        reg.register(register_request("w1", None, "/tmp/a"));
810        assert!(reg.unregister("w1"));
811        // Removing again is a no-op.
812        assert!(!reg.unregister("w1"));
813    }
814
815    #[test]
816    fn first_folder_returns_first_folder_or_none() {
817        let reg = WorktreesRegistry::new();
818        // No such key.
819        assert!(reg.first_folder("missing").is_none());
820        reg.register(register_request("w1", None, "/tmp/a"));
821        assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
822        // A folderless window resolves to None rather than a folder.
823        reg.register(RegisterRequest {
824            key: "w2".to_string(),
825            folders: vec![],
826            repo: None,
827            title: None,
828            pid: None,
829        });
830        assert!(reg.first_folder("w2").is_none());
831    }
832
833    #[test]
834    fn open_folders_dedups_and_sorts_across_windows() {
835        let reg = WorktreesRegistry::new();
836        assert!(reg.open_folders().is_empty());
837        // Two windows sharing a folder, plus a multi-folder window: the shared
838        // path collapses and the result is sorted.
839        reg.register(register_request("w1", Some("repo-a"), "/tmp/shared"));
840        reg.register(RegisterRequest {
841            key: "w2".to_string(),
842            folders: vec![PathBuf::from("/tmp/shared"), PathBuf::from("/tmp/b")],
843            repo: Some("repo-a".to_string()),
844            title: None,
845            pid: None,
846        });
847        reg.register(register_request("w3", Some("repo-b"), "/tmp/a"));
848        assert_eq!(
849            reg.open_folders(),
850            vec![
851                PathBuf::from("/tmp/a"),
852                PathBuf::from("/tmp/b"),
853                PathBuf::from("/tmp/shared"),
854            ]
855        );
856    }
857
858    #[test]
859    fn open_folders_reaps_stale_windows() {
860        let reg = WorktreesRegistry::new();
861        {
862            let mut windows = reg.lock();
863            windows.insert(
864                "fresh".to_string(),
865                WindowEntry {
866                    key: "fresh".to_string(),
867                    folders: vec![PathBuf::from("/tmp/fresh")],
868                    repo: None,
869                    title: None,
870                    pid: None,
871                    last_seen: Utc::now(),
872                },
873            );
874            windows.insert(
875                "stale".to_string(),
876                WindowEntry {
877                    key: "stale".to_string(),
878                    folders: vec![PathBuf::from("/tmp/stale")],
879                    repo: None,
880                    title: None,
881                    pid: None,
882                    last_seen: Utc::now() - chrono::Duration::seconds(120),
883                },
884            );
885        }
886        // The stale window's folder is reaped out of the snapshot.
887        assert_eq!(reg.open_folders(), vec![PathBuf::from("/tmp/fresh")]);
888    }
889
890    #[test]
891    fn reap_evicts_only_stale_entries() {
892        let now = Utc::now();
893        let mut windows = HashMap::new();
894        windows.insert(
895            "fresh".to_string(),
896            WindowEntry {
897                key: "fresh".to_string(),
898                folders: vec![],
899                repo: None,
900                title: None,
901                pid: None,
902                last_seen: now - chrono::Duration::seconds(5),
903            },
904        );
905        windows.insert(
906            "stale".to_string(),
907            WindowEntry {
908                key: "stale".to_string(),
909                folders: vec![],
910                repo: None,
911                title: None,
912                pid: None,
913                last_seen: now - chrono::Duration::seconds(120),
914            },
915        );
916        reap(&mut windows, DEFAULT_TTL, now);
917        assert!(windows.contains_key("fresh"));
918        assert!(!windows.contains_key("stale"));
919    }
920
921    // --- Outage credit (#2111) ----------------------------------------------
922
923    fn insert_aged(reg: &WorktreesRegistry, key: &str, age_secs: i64) {
924        reg.lock().insert(
925            key.to_string(),
926            entry_at(key, Utc::now() - ChronoDuration::seconds(age_secs)),
927        );
928    }
929
930    #[test]
931    fn credit_outage_keeps_a_window_the_outage_alone_would_have_expired() {
932        let reg = WorktreesRegistry::new();
933        // 40 s silent is past the 30 s TTL, but 25 s of it was the daemon's own
934        // outage, so it has really been silent for 15 s.
935        insert_aged(&reg, "w", 40);
936        reg.credit_outage(Duration::from_secs(25));
937        assert_eq!(reg.list().len(), 1, "an outage must not age a window out");
938    }
939
940    #[test]
941    fn without_a_credit_the_same_window_is_reaped() {
942        let reg = WorktreesRegistry::new();
943        insert_aged(&reg, "w", 40);
944        assert!(reg.list().is_empty(), "the control for the test above");
945    }
946
947    #[test]
948    fn credit_outage_does_not_resurrect_a_window_that_was_already_gone() {
949        let reg = WorktreesRegistry::new();
950        insert_aged(&reg, "dead", 120);
951        // Reaped before the credit lands (any read does this).
952        assert!(reg.list().is_empty());
953        reg.credit_outage(Duration::from_secs(300));
954        assert!(reg.list().is_empty());
955    }
956
957    #[test]
958    fn credit_outage_never_moves_last_seen_into_the_future() {
959        let reg = WorktreesRegistry::new();
960        insert_aged(&reg, "w", 1);
961        reg.credit_outage(Duration::from_secs(3600));
962        let after = reg.lock()["w"].last_seen;
963        assert!(after <= Utc::now(), "last_seen is ahead of the clock");
964        // Even a duration chrono cannot represent is harmless.
965        reg.credit_outage(Duration::MAX);
966        assert!(reg.lock()["w"].last_seen <= Utc::now());
967    }
968
969    #[test]
970    fn credit_outage_is_not_a_visible_change() {
971        let reg = WorktreesRegistry::new();
972        insert_aged(&reg, "w", 10);
973        let rx = reg.subscribe_changes();
974        reg.credit_outage(Duration::from_secs(5));
975        assert!(
976            !rx.has_changed().unwrap(),
977            "crediting must not wake every subscriber"
978        );
979    }
980
981    /// A minimal entry for cap/eviction tests; only `key` and `last_seen`
982    /// participate in eviction order.
983    fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
984        WindowEntry {
985            key: key.to_string(),
986            folders: vec![],
987            repo: None,
988            title: None,
989            pid: None,
990            last_seen,
991        }
992    }
993
994    #[test]
995    fn evict_oldest_removes_oldest_with_key_tiebreak() {
996        let now = Utc::now();
997        let mut windows = HashMap::new();
998        windows.insert("young".to_string(), entry_at("young", now));
999        windows.insert(
1000            "old-b".to_string(),
1001            entry_at("old-b", now - chrono::Duration::seconds(10)),
1002        );
1003        windows.insert(
1004            "old-a".to_string(),
1005            entry_at("old-a", now - chrono::Duration::seconds(10)),
1006        );
1007        // Oldest `last_seen` is shared by two entries; the lowest key loses.
1008        evict_oldest(&mut windows);
1009        assert!(!windows.contains_key("old-a"));
1010        assert!(windows.contains_key("old-b"));
1011        assert!(windows.contains_key("young"));
1012        // Empty map is a no-op rather than a panic.
1013        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
1014        evict_oldest(&mut empty);
1015        assert!(empty.is_empty());
1016    }
1017
1018    #[test]
1019    fn register_at_cap_evicts_only_the_oldest() {
1020        let reg = WorktreesRegistry::new();
1021        // Seed a full registry directly (registering 256 times would work too,
1022        // but sub-second timestamps may tie; explicit timestamps make the
1023        // highest-numbered key unambiguously the oldest).
1024        {
1025            let mut windows = reg.lock();
1026            let base = Utc::now();
1027            for i in 0..MAX_WINDOWS {
1028                let key = format!("w{i:03}");
1029                windows.insert(
1030                    key.clone(),
1031                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
1032                );
1033            }
1034        }
1035        // A new key at the cap displaces exactly the longest-silent entry.
1036        reg.register(register_request("fresh", None, "/tmp/f"));
1037        let windows = reg.lock();
1038        assert_eq!(windows.len(), MAX_WINDOWS);
1039        assert!(windows.contains_key("fresh"));
1040        assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
1041        assert!(windows.contains_key("w000"));
1042    }
1043
1044    #[test]
1045    fn register_upsert_at_cap_does_not_evict() {
1046        let reg = WorktreesRegistry::new();
1047        {
1048            let mut windows = reg.lock();
1049            let base = Utc::now();
1050            for i in 0..MAX_WINDOWS {
1051                let key = format!("w{i:03}");
1052                windows.insert(
1053                    key.clone(),
1054                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
1055                );
1056            }
1057        }
1058        // Re-registering an existing key is an upsert: nothing is displaced,
1059        // not even the oldest entry.
1060        let oldest = format!("w{:03}", MAX_WINDOWS - 1);
1061        reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
1062        let windows = reg.lock();
1063        assert_eq!(windows.len(), MAX_WINDOWS);
1064        assert!(windows.contains_key(&oldest));
1065        assert!(windows.contains_key("w000"));
1066    }
1067
1068    #[test]
1069    fn sorted_entries_orders_by_repo_then_key() {
1070        let now = Utc::now();
1071        let mut windows = HashMap::new();
1072        for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
1073            windows.insert(
1074                key.to_string(),
1075                WindowEntry {
1076                    key: key.to_string(),
1077                    folders: vec![],
1078                    repo: Some(repo.to_string()),
1079                    title: None,
1080                    pid: None,
1081                    last_seen: now,
1082                },
1083            );
1084        }
1085        let entries = sorted_entries(&windows);
1086        let ordered: Vec<(&str, &str)> = entries
1087            .iter()
1088            .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
1089            .collect();
1090        assert_eq!(
1091            ordered,
1092            vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
1093        );
1094    }
1095
1096    #[test]
1097    fn default_constructs_an_empty_registry() {
1098        let reg = WorktreesRegistry::default();
1099        assert!(reg.lock().is_empty());
1100    }
1101
1102    // --- Change-notify for the push subscription (#1267) --------------------
1103
1104    #[test]
1105    fn subscribe_changes_starts_seen_and_register_bumps() {
1106        let reg = WorktreesRegistry::new();
1107        let mut rx = reg.subscribe_changes();
1108        // A fresh receiver has the current version already marked seen.
1109        assert!(!rx.has_changed().unwrap());
1110        // A register changes the visible set → the receiver observes a new value.
1111        reg.register(register_request("w1", None, "/tmp/a"));
1112        assert!(rx.has_changed().unwrap(), "register should bump");
1113        // Marking it seen clears the pending change.
1114        rx.borrow_and_update();
1115        assert!(!rx.has_changed().unwrap());
1116    }
1117
1118    #[test]
1119    fn unregister_bumps_only_when_it_removes() {
1120        let reg = WorktreesRegistry::new();
1121        reg.register(register_request("w1", None, "/tmp/a"));
1122        // Subscribe *after* the register so its bump is already seen.
1123        let rx = reg.subscribe_changes();
1124        // Removing a missing key changes nothing (and reaps nothing) → no bump.
1125        assert!(!reg.unregister("ghost"));
1126        assert!(
1127            !rx.has_changed().unwrap(),
1128            "a no-op unregister must not bump"
1129        );
1130        // Removing a present key bumps.
1131        assert!(reg.unregister("w1"));
1132        assert!(
1133            rx.has_changed().unwrap(),
1134            "a removing unregister should bump"
1135        );
1136    }
1137
1138    #[test]
1139    fn change_generation_advances_only_on_a_visible_change() {
1140        let reg = WorktreesRegistry::new();
1141        let g0 = reg.change_generation();
1142        // A no-op (heartbeat of an unknown key) leaves the generation untouched.
1143        assert!(!reg.heartbeat("ghost"));
1144        assert_eq!(
1145            reg.change_generation(),
1146            g0,
1147            "a no-op must not advance the generation"
1148        );
1149        // A register changes the visible set → the generation advances, so a
1150        // cache keyed on it rebuilds.
1151        reg.register(register_request("w1", None, "/tmp/a"));
1152        assert_ne!(
1153            reg.change_generation(),
1154            g0,
1155            "a register should advance the generation"
1156        );
1157    }
1158
1159    #[test]
1160    fn heartbeat_bumps_only_when_it_reaps() {
1161        let reg = WorktreesRegistry::new();
1162        reg.register(register_request("w1", None, "/tmp/a"));
1163        let rx = reg.subscribe_changes();
1164        // A plain heartbeat refreshes liveness but changes no visible state.
1165        assert!(reg.heartbeat("w1"));
1166        assert!(!rx.has_changed().unwrap(), "a pure heartbeat must not bump");
1167        // Seed a stale sibling directly; a heartbeat that reaps it *does* bump.
1168        {
1169            let mut windows = reg.lock();
1170            windows.insert(
1171                "stale".to_string(),
1172                entry_at("stale", Utc::now() - chrono::Duration::seconds(120)),
1173            );
1174        }
1175        assert!(reg.heartbeat("w1"));
1176        assert!(
1177            rx.has_changed().unwrap(),
1178            "a heartbeat that reaps a stale sibling should bump"
1179        );
1180    }
1181
1182    // --- Close-pending directive (#1277) -----------------------------------
1183
1184    #[test]
1185    fn close_pending_is_taken_once_then_cleared() {
1186        let reg = WorktreesRegistry::new();
1187        // No directive by default.
1188        assert!(!reg.take_close_pending("w1"));
1189        // Marked → the first take observes it, the next does not (fires once).
1190        reg.mark_close_pending("w1");
1191        assert!(reg.take_close_pending("w1"));
1192        assert!(!reg.take_close_pending("w1"));
1193    }
1194
1195    #[test]
1196    fn unregister_clears_a_pending_close_directive() {
1197        let reg = WorktreesRegistry::new();
1198        reg.register(register_request("w1", None, "/tmp/a"));
1199        reg.mark_close_pending("w1");
1200        // Unregistering the window drops any pending directive with it.
1201        assert!(reg.unregister("w1"));
1202        assert!(!reg.take_close_pending("w1"));
1203    }
1204
1205    // --- Reload-pending directive (#1417) ----------------------------------
1206
1207    #[test]
1208    fn reload_pending_is_taken_once_then_cleared() {
1209        let reg = WorktreesRegistry::new();
1210        // No directive by default.
1211        assert!(!reg.take_reload_pending("w1"));
1212        // Marked → the first take observes it, the next does not (fires once).
1213        reg.mark_reload_pending("w1");
1214        assert!(reg.take_reload_pending("w1"));
1215        assert!(!reg.take_reload_pending("w1"));
1216    }
1217
1218    #[test]
1219    fn unregister_clears_a_pending_reload_directive() {
1220        let reg = WorktreesRegistry::new();
1221        reg.register(register_request("w1", None, "/tmp/a"));
1222        reg.mark_reload_pending("w1");
1223        // Unregistering the window drops any pending directive with it.
1224        assert!(reg.unregister("w1"));
1225        assert!(!reg.take_reload_pending("w1"));
1226    }
1227
1228    #[test]
1229    fn close_and_reload_directives_are_independent() {
1230        let reg = WorktreesRegistry::new();
1231        // Separate sets: marking one must not set or consume the other, so the
1232        // heartbeat can surface both fields and the companion pick a winner.
1233        reg.mark_reload_pending("w1");
1234        assert!(!reg.take_close_pending("w1"));
1235        reg.mark_close_pending("w1");
1236        assert!(reg.take_close_pending("w1"));
1237        assert!(reg.take_reload_pending("w1"));
1238        // Each key is tracked on its own.
1239        reg.mark_reload_pending("w1");
1240        assert!(!reg.take_reload_pending("w2"));
1241        assert!(reg.take_reload_pending("w1"));
1242    }
1243
1244    #[test]
1245    fn marking_a_reload_does_not_bump() {
1246        let reg = WorktreesRegistry::new();
1247        reg.register(register_request("w1", None, "/tmp/a"));
1248        let rx = reg.subscribe_changes();
1249        // A directive is not consumer-visible state — it never reaches a tree
1250        // snapshot — so marking one must not push a redundant frame to every
1251        // subscriber. Same rule as the close directive.
1252        reg.mark_reload_pending("w1");
1253        assert!(!rx.has_changed().unwrap(), "marking a reload must not bump");
1254        assert!(reg.take_reload_pending("w1"));
1255        assert!(!rx.has_changed().unwrap(), "taking a reload must not bump");
1256    }
1257
1258    // --- Rebasing set (#1415) ----------------------------------------------
1259
1260    #[test]
1261    fn rebasing_marks_and_clears_the_named_paths() {
1262        let reg = WorktreesRegistry::new();
1263        let a = PathBuf::from("/tmp/a");
1264        let b = PathBuf::from("/tmp/b");
1265        assert!(
1266            reg.rebasing_paths().is_empty(),
1267            "nothing rebases by default"
1268        );
1269
1270        assert!(reg.mark_rebasing(&[a.clone(), b.clone()]));
1271        assert_eq!(reg.rebasing_paths(), [a.clone(), b.clone()].into());
1272
1273        // Clearing one leaves the other — a batch reports per worktree.
1274        assert!(reg.clear_rebasing(std::slice::from_ref(&a)));
1275        assert_eq!(reg.rebasing_paths(), [b.clone()].into());
1276        assert!(reg.clear_rebasing(&[b]));
1277        assert!(reg.rebasing_paths().is_empty());
1278    }
1279
1280    #[test]
1281    fn rebasing_bumps_only_on_a_real_change() {
1282        // Load-bearing: an unconditional bump would defeat the server's snapshot
1283        // diff and re-push a `tree` frame to every window on every tick.
1284        let reg = WorktreesRegistry::new();
1285        let path = PathBuf::from("/tmp/a");
1286
1287        let mut rx = reg.subscribe_changes();
1288        assert!(reg.mark_rebasing(std::slice::from_ref(&path)));
1289        assert!(rx.has_changed().unwrap(), "the first mark bumps");
1290        let _ = rx.borrow_and_update();
1291
1292        assert!(
1293            !reg.mark_rebasing(std::slice::from_ref(&path)),
1294            "re-marking an already-rebasing path is not a change"
1295        );
1296        assert!(!rx.has_changed().unwrap(), "a no-op mark must not bump");
1297
1298        assert!(
1299            !reg.clear_rebasing(&[PathBuf::from("/tmp/never-marked")]),
1300            "clearing an unmarked path is not a change"
1301        );
1302        assert!(!rx.has_changed().unwrap(), "a no-op clear must not bump");
1303
1304        assert!(reg.clear_rebasing(&[path]));
1305        assert!(rx.has_changed().unwrap(), "a real clear bumps");
1306    }
1307
1308    // --- Pushing set (#1443) -----------------------------------------------
1309
1310    #[test]
1311    fn pushing_marks_and_clears_the_named_paths() {
1312        let reg = WorktreesRegistry::new();
1313        let a = PathBuf::from("/tmp/a");
1314        let b = PathBuf::from("/tmp/b");
1315        assert!(reg.pushing_paths().is_empty(), "nothing pushes by default");
1316
1317        assert!(reg.mark_pushing(&[a.clone(), b.clone()]));
1318        assert_eq!(reg.pushing_paths(), [a.clone(), b.clone()].into());
1319
1320        assert!(reg.clear_pushing(std::slice::from_ref(&a)));
1321        assert_eq!(reg.pushing_paths(), [b.clone()].into());
1322        assert!(reg.clear_pushing(&[b]));
1323        assert!(reg.pushing_paths().is_empty());
1324    }
1325
1326    #[test]
1327    fn pushing_bumps_only_on_a_real_change() {
1328        let reg = WorktreesRegistry::new();
1329        let path = PathBuf::from("/tmp/a");
1330
1331        let mut rx = reg.subscribe_changes();
1332        assert!(reg.mark_pushing(std::slice::from_ref(&path)));
1333        assert!(rx.has_changed().unwrap(), "the first mark bumps");
1334        let _ = rx.borrow_and_update();
1335
1336        assert!(
1337            !reg.mark_pushing(std::slice::from_ref(&path)),
1338            "re-marking an already-pushing path is not a change"
1339        );
1340        assert!(!rx.has_changed().unwrap(), "a no-op mark must not bump");
1341
1342        assert!(reg.clear_pushing(&[path]));
1343        assert!(rx.has_changed().unwrap(), "a real clear bumps");
1344    }
1345
1346    #[test]
1347    fn pushing_and_rebasing_are_independent_sets() {
1348        // The two cues are orthogonal — the same worktree can be marked by one op
1349        // without the other's cue appearing, and clearing one must not clear the
1350        // other.
1351        let reg = WorktreesRegistry::new();
1352        let path = PathBuf::from("/tmp/a");
1353
1354        assert!(reg.mark_rebasing(std::slice::from_ref(&path)));
1355        assert!(
1356            reg.pushing_paths().is_empty(),
1357            "a rebase mark must not show as a push"
1358        );
1359
1360        assert!(reg.mark_pushing(std::slice::from_ref(&path)));
1361        assert!(reg.clear_rebasing(std::slice::from_ref(&path)));
1362        assert_eq!(
1363            reg.pushing_paths(),
1364            [path].into(),
1365            "clearing the rebase mark must leave the push mark alone"
1366        );
1367    }
1368
1369    // --- Show/hide-closed toggle (#1301) -----------------------------------
1370
1371    #[test]
1372    fn show_closed_defaults_to_true() {
1373        let reg = WorktreesRegistry::new();
1374        assert!(reg.show_closed(), "default is show all");
1375    }
1376
1377    #[test]
1378    fn set_show_closed_reports_change_and_is_idempotent() {
1379        let reg = WorktreesRegistry::new();
1380        // Flipping to a new value reports a change and is observable.
1381        assert!(reg.set_show_closed(false));
1382        assert!(!reg.show_closed());
1383        // Setting the same value again is a no-op (no change reported).
1384        assert!(!reg.set_show_closed(false));
1385        // Flipping back reports a change again.
1386        assert!(reg.set_show_closed(true));
1387        assert!(reg.show_closed());
1388    }
1389
1390    #[test]
1391    fn set_show_closed_bumps_only_on_change() {
1392        let reg = WorktreesRegistry::new();
1393        let rx = reg.subscribe_changes();
1394        // A no-op set (already the default) does not wake subscribers.
1395        assert!(!reg.set_show_closed(true));
1396        assert!(
1397            !rx.has_changed().unwrap(),
1398            "a no-op toggle must not bump the change-notify"
1399        );
1400        // A real flip bumps so subscribers re-push a snapshot with the new value.
1401        assert!(reg.set_show_closed(false));
1402        assert!(
1403            rx.has_changed().unwrap(),
1404            "flipping the toggle should bump the change-notify"
1405        );
1406    }
1407
1408    #[test]
1409    fn polling_defaults_off_for_an_untoggled_repo() {
1410        let reg = WorktreesRegistry::new();
1411        // #1376: the whole point — a repo the user has never enabled is not polled.
1412        assert!(!reg.is_polling_enabled("rust-works", "omni-dev"));
1413        assert!(reg.enabled_polling_repos().is_empty());
1414        assert!(reg.polling_snapshot().is_empty());
1415    }
1416
1417    #[test]
1418    fn set_polling_leases_and_disables() {
1419        let reg = WorktreesRegistry::new();
1420        // Enabling a fresh repo reports a change and leases it live.
1421        assert!(reg.set_polling("rust-works", "omni-dev", true));
1422        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1423        // Re-enabling refreshes the lease — the map changes (new expiry).
1424        assert!(reg.set_polling("rust-works", "omni-dev", true));
1425        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1426        // Disabling reports a change and clears it.
1427        assert!(reg.set_polling("rust-works", "omni-dev", false));
1428        assert!(!reg.is_polling_enabled("rust-works", "omni-dev"));
1429        // Disabling an already-disabled repo is a no-op.
1430        assert!(!reg.set_polling("rust-works", "omni-dev", false));
1431    }
1432
1433    #[test]
1434    fn polling_lease_auto_expires() {
1435        // The 15-minute lease (#1376): an enabled repo drops out once its lease
1436        // elapses, reaped on the next read — no background timer.
1437        let reg = WorktreesRegistry::new();
1438        reg.set_polling("rust-works", "omni-dev", true);
1439        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1440        // Force the lease into the past (as if 15 min elapsed).
1441        reg.set_polling_expiry(
1442            "rust-works",
1443            "omni-dev",
1444            Utc::now() - ChronoDuration::seconds(1),
1445        );
1446        assert!(
1447            !reg.is_polling_enabled("rust-works", "omni-dev"),
1448            "an expired lease reads as disabled"
1449        );
1450        // The read paths reap it, so it is gone from the snapshot entirely.
1451        assert!(reg.enabled_polling_repos().is_empty());
1452        assert!(reg.polling_snapshot().is_empty());
1453        // A tiny real TTL expires on its own after a short sleep.
1454        let short = WorktreesRegistry::with_poll_ttl(Duration::from_millis(30));
1455        short.set_polling("o", "n", true);
1456        assert!(short.is_polling_enabled("o", "n"));
1457        std::thread::sleep(Duration::from_millis(60));
1458        assert!(!short.is_polling_enabled("o", "n"));
1459    }
1460
1461    #[test]
1462    fn set_polling_bumps_only_on_an_effective_flip() {
1463        let reg = WorktreesRegistry::new();
1464        let rx = reg.subscribe_changes();
1465        // A no-op (disabling an already-off repo) does not wake subscribers.
1466        assert!(!reg.set_polling("o", "n", false));
1467        assert!(
1468            !rx.has_changed().unwrap(),
1469            "a no-op poll toggle must not bump the change-notify"
1470        );
1471        // A real enable flips off→on and bumps so every window recolors.
1472        assert!(reg.set_polling("o", "n", true));
1473        assert!(
1474            rx.has_changed().unwrap(),
1475            "enabling a repo should bump the change-notify"
1476        );
1477    }
1478
1479    #[test]
1480    fn refreshing_a_live_lease_does_not_bump() {
1481        // A lease refresh (already on, still on) persists a new expiry but does
1482        // not change the visible state, so it must not wake every window.
1483        let reg = WorktreesRegistry::new();
1484        reg.set_polling("o", "n", true);
1485        let rx = reg.subscribe_changes();
1486        assert!(
1487            reg.set_polling("o", "n", true),
1488            "re-enabling refreshes the lease (map changed → persist)"
1489        );
1490        assert!(
1491            !rx.has_changed().unwrap(),
1492            "refreshing a live lease must not bump — the visible state is unchanged"
1493        );
1494    }
1495
1496    #[test]
1497    fn seed_polling_loads_leases_and_drops_expired() {
1498        let reg = WorktreesRegistry::new();
1499        reg.set_polling("a", "z", true);
1500        let future = Utc::now() + ChronoDuration::minutes(10);
1501        let past = Utc::now() - ChronoDuration::minutes(1);
1502        // Seeding (the startup load) replaces wholesale, dropping the prior entry
1503        // and any already-expired lease from the file.
1504        reg.seed_polling([
1505            ("rust-works/omni-dev".to_string(), future),
1506            ("acme/widgets".to_string(), future),
1507            ("stale/repo".to_string(), past),
1508        ]);
1509        assert!(!reg.is_polling_enabled("a", "z"));
1510        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1511        assert!(
1512            !reg.is_polling_enabled("stale", "repo"),
1513            "expired lease dropped"
1514        );
1515        // The persisted form is deterministic (sorted) and carries the expiries.
1516        let snap = reg.polling_snapshot();
1517        assert_eq!(
1518            snap.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>(),
1519            vec![
1520                "acme/widgets".to_string(),
1521                "rust-works/omni-dev".to_string()
1522            ]
1523        );
1524        assert!(snap.iter().all(|(_, expiry)| *expiry == future));
1525    }
1526}