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    /// The daemon-backed **show/hide-closed** toggle (#1301): whether the
163    /// companion's tree view shows worktrees with no open window. A single
164    /// cross-window value carried in every `tree`/`subscribe` snapshot so all
165    /// windows read (and live-sync) the same state — `context.globalState` could
166    /// not, being read-once with no cross-window change event. Defaults to `true`
167    /// (show all, the original behavior). A lock-free [`AtomicBool`] rather than a
168    /// `Mutex`, so it is never a `.await`-holding-a-lock hazard; a flip
169    /// [`bump`](Self::bump)s the change-notify so subscribers re-push. In-memory
170    /// like the window map: a daemon restart resets it to the default, which the
171    /// next snapshot propagates to every window.
172    show_closed: AtomicBool,
173    /// The **per-repository PR-poll** enable set (#1376): the GitHub repos
174    /// (`"owner/name"`) whose PR badges the daemon polls. Polling defaults
175    /// **off** — a repo not in this set issues zero `gh` — so the user enables
176    /// only the handful of repos they are actively working on, rather than the
177    /// daemon polling all 29 open repos and exhausting the GitHub budget. A
178    /// cross-window value like [`show_closed`](Self::show_closed): the daemon
179    /// stamps each repo's state onto the `tree` snapshot (`polling_enabled`) and
180    /// a [`set_polling`](Self::set_polling) flip [`bump`](Self::bump)s the
181    /// change-notify so every window recolors and drops/keeps badges in sync.
182    ///
183    /// Unlike `show_closed` this survives a daemon restart: the adapter seeds it
184    /// from a `0600` file on startup ([`seed_polling`](Self::seed_polling)) and
185    /// persists it on each change — otherwise a restart would silently re-disable
186    /// every repo. Behind its **own** `Mutex`, taken independently of the window
187    /// map's (neither nests) and never held across an `.await`.
188    ///
189    /// Each enable is a **time-boxed lease**, not a permanent flag (#1376): the
190    /// value is the wall-clock instant the lease **expires** ([`poll_ttl`] after
191    /// it was enabled), so an idle repo auto-disables and stops costing `gh`
192    /// without the user remembering to turn it back off. Expired entries are
193    /// reaped on read — the window-TTL precedent — so the icon greys, badges
194    /// drop, and the poller stops within one snapshot tick of expiry.
195    ///
196    /// [`poll_ttl`]: Self::poll_ttl
197    polling_enabled: Mutex<HashMap<String, DateTime<Utc>>>,
198    /// How long a repo's PR-poll lease lasts before it auto-expires (#1376).
199    /// [`DEFAULT_POLL_LEASE`] in production; tests inject a short value via the
200    /// `#[cfg(test)]` `with_poll_ttl` constructor (not linked — it does not exist
201    /// in a non-test doc build).
202    poll_ttl: Duration,
203}
204
205impl WorktreesRegistry {
206    /// Creates the registry with the default liveness TTL. Cheap — no I/O.
207    #[must_use]
208    pub fn new() -> Self {
209        Self {
210            windows: Mutex::new(HashMap::new()),
211            ttl: DEFAULT_TTL,
212            changes: watch::channel(0).0,
213            close_pending: Mutex::new(HashSet::new()),
214            reload_pending: Mutex::new(HashSet::new()),
215            rebasing: Mutex::new(HashSet::new()),
216            show_closed: AtomicBool::new(true),
217            polling_enabled: Mutex::new(HashMap::new()),
218            poll_ttl: DEFAULT_POLL_LEASE,
219        }
220    }
221
222    /// Creates a registry with a custom PR-poll lease duration, for tests that
223    /// exercise auto-expiry without waiting the full 15 minutes.
224    #[cfg(test)]
225    #[must_use]
226    pub fn with_poll_ttl(poll_ttl: Duration) -> Self {
227        Self {
228            poll_ttl,
229            ..Self::new()
230        }
231    }
232
233    /// A change-notification receiver for the push subscription: it observes a
234    /// new value each time the visible window set changes (see [`changes`] and
235    /// [`bump`]). Created with the current version already marked seen, so the
236    /// first [`watch::Receiver::changed`] resolves on the *next* change — the
237    /// subscriber sends its own initial snapshot up front and then waits for
238    /// deltas (#1267).
239    ///
240    /// [`changes`]: Self::changes
241    /// [`bump`]: Self::bump
242    #[must_use]
243    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
244        self.changes.subscribe()
245    }
246
247    /// Signals subscribers that the visible state changed. Non-blocking and
248    /// runtime-free; called only *after* the map guard is released so the two
249    /// locks never nest. A send never fails here (the sender is owned by the
250    /// registry, which outlives every receiver, and `send_modify` bumps even
251    /// with no receivers).
252    ///
253    /// Visible outside the registry so the daemon's PR badge poller can signal a
254    /// changed CI verdict (#1337) — the tree snapshot carries more than the window
255    /// set. Callers must bump **only on a real change**: an unconditional bump
256    /// defeats the server's snapshot diff and re-pushes to every window on every
257    /// tick.
258    pub(crate) fn bump(&self) {
259        self.changes.send_modify(|v| *v = v.wrapping_add(1));
260    }
261
262    /// The current change-notify generation — the counter [`bump`](Self::bump)
263    /// increments on every visible-set change. Read (never subscribed) so a
264    /// coalescing consumer — the service's shared tree-snapshot cache (#1303) —
265    /// can tell whether the registry has changed since it last computed and
266    /// rebuild only then. The value itself is immaterial; only whether it
267    /// differs between two reads matters, so `wrapping_add` overflow is benign.
268    #[must_use]
269    pub fn change_generation(&self) -> u64 {
270        *self.changes.borrow()
271    }
272
273    /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
274    /// critical section must not wedge the whole registry).
275    fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
276        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
277    }
278
279    /// Records (upserts) a window registration. Reaps stale entries first, then
280    /// — only when a genuinely new key would grow the map past [`MAX_WINDOWS`] —
281    /// evicts the longest-silent entry. Infallible: an upsert never evicts, and
282    /// callers validate the `key` before reaching here.
283    pub fn register(&self, req: RegisterRequest) {
284        let now = Utc::now();
285        {
286            let mut windows = self.lock();
287            reap(&mut windows, self.ttl, now);
288            // Upserts never evict; only a genuinely new key can grow the map, and
289            // never past MAX_WINDOWS.
290            if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
291                evict_oldest(&mut windows);
292            }
293            windows.insert(
294                req.key.clone(),
295                WindowEntry {
296                    key: req.key,
297                    folders: req.folders,
298                    repo: req.repo,
299                    title: req.title,
300                    pid: req.pid,
301                    last_seen: now,
302                },
303            );
304        }
305        // Always bump: a register is infrequent (once per companion `activate()`,
306        // not per heartbeat) and may add or alter a window's folders/repo. A
307        // no-op re-register with identical data is harmless — the subscriber
308        // diffs the snapshot and suppresses the duplicate frame.
309        self.bump();
310    }
311
312    /// Refreshes a window's liveness. Returns whether the key was known: a
313    /// `false` tells a window that started before the daemon — or survived a
314    /// daemon restart — to re-`register`, since the registry is in-memory and
315    /// has no record of it.
316    pub fn heartbeat(&self, key: &str) -> bool {
317        let now = Utc::now();
318        let (known, reaped) = {
319            let mut windows = self.lock();
320            let reaped = reap(&mut windows, self.ttl, now);
321            let known = match windows.get_mut(key) {
322                Some(entry) => {
323                    entry.last_seen = now;
324                    true
325                }
326                None => false,
327            };
328            (known, reaped)
329        };
330        // A heartbeat is frequent (~every 10 s per window); a pure liveness
331        // refresh does not change the visible set, so bump *only* when this
332        // heartbeat's inline reap actually aged a stale sibling out.
333        if reaped > 0 {
334            self.bump();
335        }
336        known
337    }
338
339    /// Drops a window's registration. Returns whether an entry was present.
340    pub fn unregister(&self, key: &str) -> bool {
341        let now = Utc::now();
342        let (removed, reaped) = {
343            let mut windows = self.lock();
344            let removed = windows.remove(key).is_some();
345            let reaped = reap(&mut windows, self.ttl, now);
346            (removed, reaped)
347        };
348        // The window is gone; any directive for it is fulfilled or moot.
349        // (Keys are per-`activate()` UUIDs, never reused, so a stale directive
350        // would only ever leak a little memory — but clearing keeps it tidy.)
351        // Both takes are outside the map's critical section, so no lock nests.
352        self.take_close_pending(key);
353        self.take_reload_pending(key);
354        if removed || reaped > 0 {
355            self.bump();
356        }
357        removed
358    }
359
360    /// Records a pending "close yourself" directive for `key`, to be surfaced on
361    /// that window's next `heartbeat`. Set by the `close` op when signalling a
362    /// window it can only reply to. Idempotent; infallible.
363    pub fn mark_close_pending(&self, key: &str) {
364        self.close_pending
365            .lock()
366            .unwrap_or_else(PoisonError::into_inner)
367            .insert(key.to_string());
368    }
369
370    /// Takes (returns and clears) `key`'s pending close directive. Called on
371    /// each `heartbeat` so the directive fires exactly once; a `false` means no
372    /// close is pending.
373    pub fn take_close_pending(&self, key: &str) -> bool {
374        self.close_pending
375            .lock()
376            .unwrap_or_else(PoisonError::into_inner)
377            .remove(key)
378    }
379
380    /// Records a pending "reload yourself" directive for `key`, to be surfaced
381    /// on that window's next `heartbeat` (#1417). Set by the `reload` op for
382    /// every target window, which — unlike a close — includes no waiting: the
383    /// caller learns only that the directive was marked. Idempotent; infallible.
384    pub fn mark_reload_pending(&self, key: &str) {
385        self.reload_pending
386            .lock()
387            .unwrap_or_else(PoisonError::into_inner)
388            .insert(key.to_string());
389    }
390
391    /// Takes (returns and clears) `key`'s pending reload directive. Called on
392    /// each `heartbeat` so the directive fires exactly once; a `false` means no
393    /// reload is pending.
394    pub fn take_reload_pending(&self, key: &str) -> bool {
395        self.reload_pending
396            .lock()
397            .unwrap_or_else(PoisonError::into_inner)
398            .remove(key)
399    }
400
401    /// Marks `paths` as being rebased right now (#1415), returning whether the
402    /// set actually changed.
403    ///
404    /// A real change [`bump`](Self::bump)s the change-notify so every subscribed
405    /// window re-pushes a snapshot carrying the spinner — the same cross-window
406    /// sync `set_show_closed` / `set_polling` rely on. Bumping **only** on a real
407    /// change is load-bearing rather than an optimization: an unconditional bump
408    /// defeats the server's snapshot diff and re-pushes to every window on every
409    /// tick.
410    ///
411    /// Callers pass **already-canonicalized** paths, so these match the tree
412    /// snapshot's own keys. Canonicalizing is disk I/O, which belongs in the
413    /// adapter (where `canonical()` lives), not in this engine — the same split
414    /// that keeps the git enrichment out of here.
415    pub fn mark_rebasing(&self, paths: &[PathBuf]) -> bool {
416        self.mutate_rebasing(paths, true)
417    }
418
419    /// Clears the rebasing mark on `paths`, returning whether the set changed.
420    /// Called on **every** exit from a phase-2 execute, so a panicking or failing
421    /// rebase can never leave a permanent spinner on a row.
422    pub fn clear_rebasing(&self, paths: &[PathBuf]) -> bool {
423        self.mutate_rebasing(paths, false)
424    }
425
426    /// The shared body of [`mark_rebasing`](Self::mark_rebasing) /
427    /// [`clear_rebasing`](Self::clear_rebasing): mutate under the set's own lock,
428    /// drop the guard, then bump if anything moved (never bump while holding a
429    /// lock, per the engine's `std::Mutex`-never-across-`.await` discipline).
430    fn mutate_rebasing(&self, paths: &[PathBuf], insert: bool) -> bool {
431        let changed = {
432            let mut set = self.rebasing.lock().unwrap_or_else(PoisonError::into_inner);
433            paths.iter().fold(false, |changed, path| {
434                let moved = if insert {
435                    set.insert(path.clone())
436                } else {
437                    set.remove(path)
438                };
439                changed || moved
440            })
441        };
442        if changed {
443            self.bump();
444        }
445        changed
446    }
447
448    /// The worktree paths currently being rebased, canonicalized. Read into each
449    /// `tree`/`subscribe` snapshot; cheap (a set clone of at most a batch's worth
450    /// of paths) and never held across an `.await`.
451    #[must_use]
452    pub fn rebasing_paths(&self) -> HashSet<PathBuf> {
453        self.rebasing
454            .lock()
455            .unwrap_or_else(PoisonError::into_inner)
456            .clone()
457    }
458
459    /// The current show/hide-closed toggle: whether the tree view shows
460    /// worktrees with no open window (#1301). Read into every `tree`/`subscribe`
461    /// snapshot so every window renders the same, live-synced state.
462    #[must_use]
463    pub fn show_closed(&self) -> bool {
464        self.show_closed.load(Ordering::Relaxed)
465    }
466
467    /// Sets the show/hide-closed toggle, returning whether the value actually
468    /// changed. A real change [`bump`](Self::bump)s the change-notify so every
469    /// subscriber re-pushes a snapshot carrying the new value — the reliable
470    /// cross-window sync `context.globalState` could not provide. A no-op set
471    /// (same value) neither bumps nor wakes anyone.
472    pub fn set_show_closed(&self, show_closed: bool) -> bool {
473        let changed = self.show_closed.swap(show_closed, Ordering::Relaxed) != show_closed;
474        if changed {
475            self.bump();
476        }
477        changed
478    }
479
480    /// Locks the per-repo PR-poll lease map (`"owner/name"` → lease-expiry
481    /// instant), recovering from a poisoned mutex (a panic in a prior critical
482    /// section must not wedge polling for the whole daemon).
483    fn polling_lock(&self) -> MutexGuard<'_, HashMap<String, DateTime<Utc>>> {
484        self.polling_enabled
485            .lock()
486            .unwrap_or_else(PoisonError::into_inner)
487    }
488
489    /// Whether PR polling is currently leased for the GitHub repo `owner/name`
490    /// (#1376) — the entry exists **and** its lease has not expired. Defaults
491    /// **false** (a never-toggled repo does not poll), so only repos the user has
492    /// explicitly enabled, within the last [`poll_ttl`](Self::poll_ttl), poll.
493    #[must_use]
494    pub fn is_polling_enabled(&self, owner: &str, name: &str) -> bool {
495        let now = Utc::now();
496        self.polling_lock()
497            .get(&polling_key(owner, name))
498            .is_some_and(|expiry| *expiry > now)
499    }
500
501    /// The repos with a **live** (unexpired) lease, as a set of `"owner/name"`
502    /// keys — what stamps `polling_enabled` onto the `tree` snapshot. Reaps
503    /// expired leases first (the window-TTL reap-on-read precedent), so an idle
504    /// repo drops out on the next snapshot build without a background timer.
505    /// Cloned out so the lock is never held across the (blocking-thread) tree
506    /// build that reads it.
507    #[must_use]
508    pub fn enabled_polling_repos(&self) -> HashSet<String> {
509        let now = Utc::now();
510        let mut map = self.polling_lock();
511        map.retain(|_, expiry| *expiry > now);
512        map.keys().cloned().collect()
513    }
514
515    /// The live leases as `(repo, expiry)` pairs sorted by repo, for
516    /// deterministic persistence to the `0600` prefs file (#1376) — the expiry is
517    /// stored so a daemon restart within the lease window keeps the *remaining*
518    /// time rather than resetting the clock. Reaps expired leases first, so a
519    /// stale entry is never written back.
520    #[must_use]
521    pub fn polling_snapshot(&self) -> Vec<(String, DateTime<Utc>)> {
522        let now = Utc::now();
523        let mut map = self.polling_lock();
524        map.retain(|_, expiry| *expiry > now);
525        let mut entries: Vec<(String, DateTime<Utc>)> =
526            map.iter().map(|(k, v)| (k.clone(), *v)).collect();
527        entries.sort_by(|a, b| a.0.cmp(&b.0));
528        entries
529    }
530
531    /// Enables (leases for [`poll_ttl`](Self::poll_ttl)) or disables PR polling
532    /// for `owner/name`, returning whether the stored map changed — which the
533    /// adapter uses to decide whether to persist. Enabling an already-leased repo
534    /// **refreshes** the lease (a new expiry), which is a change worth persisting.
535    ///
536    /// [`bump`](Self::bump)s the change-notify only when the repo's **effective**
537    /// enabled state flips (off→on or on→off), so every subscribed window
538    /// re-pushes a snapshot that recolours the icon and drops/keeps badges — the
539    /// [`set_show_closed`](Self::set_show_closed) precedent. A lease *refresh*
540    /// (already on, still on) changes the expiry but not the visible state, so it
541    /// persists without waking anyone.
542    pub fn set_polling(&self, owner: &str, name: &str, enabled: bool) -> bool {
543        let key = polling_key(owner, name);
544        let now = Utc::now();
545        let (changed, flipped) = {
546            let mut map = self.polling_lock();
547            let was_enabled = map.get(&key).is_some_and(|expiry| *expiry > now);
548            if enabled {
549                let expiry = now
550                    + ChronoDuration::from_std(self.poll_ttl).unwrap_or_else(|_| {
551                        ChronoDuration::seconds(DEFAULT_POLL_LEASE.as_secs() as i64)
552                    });
553                let changed = map.insert(key, expiry) != Some(expiry);
554                (changed, !was_enabled)
555            } else {
556                let removed = map.remove(&key).is_some();
557                (removed, was_enabled)
558            }
559        };
560        if flipped {
561            self.bump();
562        }
563        changed
564    }
565
566    /// Replaces the lease map wholesale from the persisted `0600` prefs file
567    /// (#1376), dropping any lease that already expired while the daemon was down.
568    /// Does **not** [`bump`](Self::bump): it runs before any window subscribes, so
569    /// there is no one to notify, and each window's first snapshot already
570    /// reflects the seeded leases.
571    pub fn seed_polling(&self, leases: impl IntoIterator<Item = (String, DateTime<Utc>)>) {
572        let now = Utc::now();
573        *self.polling_lock() = leases
574            .into_iter()
575            .filter(|(_, expiry)| *expiry > now)
576            .collect();
577    }
578
579    /// Test-only: forces `owner/name`'s lease to `expiry`, so a test can simulate
580    /// an elapsed lease deterministically without sleeping for [`poll_ttl`].
581    #[cfg(test)]
582    pub fn set_polling_expiry(&self, owner: &str, name: &str, expiry: DateTime<Utc>) {
583        self.polling_lock().insert(polling_key(owner, name), expiry);
584    }
585
586    /// Reaps stale entries, then returns the live set sorted for deterministic
587    /// output. Holds the lock only for pure-CPU work.
588    ///
589    /// Like the other reads ([`open_folders`](Self::open_folders),
590    /// [`first_folder`](Self::first_folder)) this reaps but never
591    /// [`bump`](Self::bump)s: the only observer of a read-path reap is the push
592    /// subscription's own re-snapshot (or `status`/`menu`), and the
593    /// subscription's periodic tick already re-samples read-only staleness — so
594    /// bumping here would only make the subscription wake itself (#1267).
595    pub fn list(&self) -> Vec<WindowEntry> {
596        let now = Utc::now();
597        let mut windows = self.lock();
598        reap(&mut windows, self.ttl, now);
599        sorted_entries(&windows)
600    }
601
602    /// The first workspace folder of a still-live window, if it has one. Used by
603    /// the tray "focus" action to resolve a key to a folder to open. Does not
604    /// reap — a menu action races the reaper either way, and the caller handles
605    /// a `None` (the window may have closed).
606    pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
607        let windows = self.lock();
608        windows.get(key).and_then(|e| e.folders.first().cloned())
609    }
610
611    /// Snapshots the distinct workspace folders across all live windows — the
612    /// seed set the adapter resolves to repositories (each folder → its git
613    /// common dir → repo root) to enumerate every worktree per repo (#1265).
614    ///
615    /// Reaps stale entries first, then returns the folders sorted and
616    /// deduplicated. Like [`list`](Self::list) it is pure CPU under the lock:
617    /// the git resolution the "distinct repos" derivation needs is disk I/O and
618    /// stays in the adapter, off the registry lock, honouring the
619    /// `Mutex`-never-across-`.await` invariant.
620    pub fn open_folders(&self) -> Vec<PathBuf> {
621        let now = Utc::now();
622        let mut windows = self.lock();
623        reap(&mut windows, self.ttl, now);
624        let mut folders: Vec<PathBuf> = windows
625            .values()
626            .flat_map(|e| e.folders.iter().cloned())
627            .collect();
628        folders.sort();
629        folders.dedup();
630        folders
631    }
632}
633
634impl Default for WorktreesRegistry {
635    fn default() -> Self {
636        Self::new()
637    }
638}
639
640/// Removes entries last seen longer than `ttl` ago, returning how many were
641/// dropped. Pure CPU; the caller holds the registry lock but never `.await`s
642/// while holding it. The count lets a *mutation* path
643/// ([`register`](WorktreesRegistry::register) et al.) decide whether to
644/// [`bump`](WorktreesRegistry::bump) the change-notify; read paths ignore it (see
645/// [`list`](WorktreesRegistry::list)).
646fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) -> usize {
647    let max_age = ttl.as_secs() as i64;
648    let before = windows.len();
649    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
650    before - windows.len()
651}
652
653/// Removes the entry with the oldest `last_seen` (ties broken by lowest key
654/// for determinism). Called when a `register` of a new key would grow the
655/// registry past [`MAX_WINDOWS`]. Pure CPU under the registry lock, like
656/// [`reap`].
657fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
658    let oldest = windows
659        .values()
660        .min_by(|a, b| {
661            a.last_seen
662                .cmp(&b.last_seen)
663                .then_with(|| a.key.cmp(&b.key))
664        })
665        .map(|e| e.key.clone());
666    if let Some(key) = oldest {
667        windows.remove(&key);
668    }
669}
670
671/// The canonical key for a GitHub repo in the per-repo PR-poll set: `owner/name`
672/// (#1376). One place so the registry's set, the snapshot stamp, and the poller
673/// filter all agree on the exact string.
674fn polling_key(owner: &str, name: &str) -> String {
675    format!("{owner}/{name}")
676}
677
678/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
679/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
680fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
681    let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
682    entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
683    entries
684}
685
686#[cfg(test)]
687#[allow(clippy::unwrap_used, clippy::expect_used)]
688mod tests {
689    use super::*;
690
691    fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
692        RegisterRequest {
693            key: key.to_string(),
694            folders: vec![PathBuf::from(folder)],
695            repo: repo.map(str::to_string),
696            title: Some(format!("{key}-title")),
697            pid: Some(1234),
698        }
699    }
700
701    #[test]
702    fn list_is_empty_initially() {
703        let reg = WorktreesRegistry::new();
704        assert!(reg.list().is_empty());
705    }
706
707    #[test]
708    fn register_then_list_round_trips() {
709        let reg = WorktreesRegistry::new();
710        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
711        let windows = reg.list();
712        assert_eq!(windows.len(), 1);
713        assert_eq!(windows[0].key, "w1");
714        assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
715    }
716
717    #[test]
718    fn register_is_idempotent_upsert() {
719        let reg = WorktreesRegistry::new();
720        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
721        // Re-registering the same key updates rather than duplicates.
722        reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
723        let windows = reg.list();
724        assert_eq!(windows.len(), 1);
725        assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
726    }
727
728    #[test]
729    fn heartbeat_reports_known_and_unknown() {
730        let reg = WorktreesRegistry::new();
731        // Unknown before registration: the window must re-register.
732        assert!(!reg.heartbeat("w1"));
733        reg.register(register_request("w1", None, "/tmp/a"));
734        assert!(reg.heartbeat("w1"));
735    }
736
737    #[test]
738    fn unregister_removes() {
739        let reg = WorktreesRegistry::new();
740        reg.register(register_request("w1", None, "/tmp/a"));
741        assert!(reg.unregister("w1"));
742        // Removing again is a no-op.
743        assert!(!reg.unregister("w1"));
744    }
745
746    #[test]
747    fn first_folder_returns_first_folder_or_none() {
748        let reg = WorktreesRegistry::new();
749        // No such key.
750        assert!(reg.first_folder("missing").is_none());
751        reg.register(register_request("w1", None, "/tmp/a"));
752        assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
753        // A folderless window resolves to None rather than a folder.
754        reg.register(RegisterRequest {
755            key: "w2".to_string(),
756            folders: vec![],
757            repo: None,
758            title: None,
759            pid: None,
760        });
761        assert!(reg.first_folder("w2").is_none());
762    }
763
764    #[test]
765    fn open_folders_dedups_and_sorts_across_windows() {
766        let reg = WorktreesRegistry::new();
767        assert!(reg.open_folders().is_empty());
768        // Two windows sharing a folder, plus a multi-folder window: the shared
769        // path collapses and the result is sorted.
770        reg.register(register_request("w1", Some("repo-a"), "/tmp/shared"));
771        reg.register(RegisterRequest {
772            key: "w2".to_string(),
773            folders: vec![PathBuf::from("/tmp/shared"), PathBuf::from("/tmp/b")],
774            repo: Some("repo-a".to_string()),
775            title: None,
776            pid: None,
777        });
778        reg.register(register_request("w3", Some("repo-b"), "/tmp/a"));
779        assert_eq!(
780            reg.open_folders(),
781            vec![
782                PathBuf::from("/tmp/a"),
783                PathBuf::from("/tmp/b"),
784                PathBuf::from("/tmp/shared"),
785            ]
786        );
787    }
788
789    #[test]
790    fn open_folders_reaps_stale_windows() {
791        let reg = WorktreesRegistry::new();
792        {
793            let mut windows = reg.lock();
794            windows.insert(
795                "fresh".to_string(),
796                WindowEntry {
797                    key: "fresh".to_string(),
798                    folders: vec![PathBuf::from("/tmp/fresh")],
799                    repo: None,
800                    title: None,
801                    pid: None,
802                    last_seen: Utc::now(),
803                },
804            );
805            windows.insert(
806                "stale".to_string(),
807                WindowEntry {
808                    key: "stale".to_string(),
809                    folders: vec![PathBuf::from("/tmp/stale")],
810                    repo: None,
811                    title: None,
812                    pid: None,
813                    last_seen: Utc::now() - chrono::Duration::seconds(120),
814                },
815            );
816        }
817        // The stale window's folder is reaped out of the snapshot.
818        assert_eq!(reg.open_folders(), vec![PathBuf::from("/tmp/fresh")]);
819    }
820
821    #[test]
822    fn reap_evicts_only_stale_entries() {
823        let now = Utc::now();
824        let mut windows = HashMap::new();
825        windows.insert(
826            "fresh".to_string(),
827            WindowEntry {
828                key: "fresh".to_string(),
829                folders: vec![],
830                repo: None,
831                title: None,
832                pid: None,
833                last_seen: now - chrono::Duration::seconds(5),
834            },
835        );
836        windows.insert(
837            "stale".to_string(),
838            WindowEntry {
839                key: "stale".to_string(),
840                folders: vec![],
841                repo: None,
842                title: None,
843                pid: None,
844                last_seen: now - chrono::Duration::seconds(120),
845            },
846        );
847        reap(&mut windows, DEFAULT_TTL, now);
848        assert!(windows.contains_key("fresh"));
849        assert!(!windows.contains_key("stale"));
850    }
851
852    /// A minimal entry for cap/eviction tests; only `key` and `last_seen`
853    /// participate in eviction order.
854    fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
855        WindowEntry {
856            key: key.to_string(),
857            folders: vec![],
858            repo: None,
859            title: None,
860            pid: None,
861            last_seen,
862        }
863    }
864
865    #[test]
866    fn evict_oldest_removes_oldest_with_key_tiebreak() {
867        let now = Utc::now();
868        let mut windows = HashMap::new();
869        windows.insert("young".to_string(), entry_at("young", now));
870        windows.insert(
871            "old-b".to_string(),
872            entry_at("old-b", now - chrono::Duration::seconds(10)),
873        );
874        windows.insert(
875            "old-a".to_string(),
876            entry_at("old-a", now - chrono::Duration::seconds(10)),
877        );
878        // Oldest `last_seen` is shared by two entries; the lowest key loses.
879        evict_oldest(&mut windows);
880        assert!(!windows.contains_key("old-a"));
881        assert!(windows.contains_key("old-b"));
882        assert!(windows.contains_key("young"));
883        // Empty map is a no-op rather than a panic.
884        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
885        evict_oldest(&mut empty);
886        assert!(empty.is_empty());
887    }
888
889    #[test]
890    fn register_at_cap_evicts_only_the_oldest() {
891        let reg = WorktreesRegistry::new();
892        // Seed a full registry directly (registering 256 times would work too,
893        // but sub-second timestamps may tie; explicit timestamps make the
894        // highest-numbered key unambiguously the oldest).
895        {
896            let mut windows = reg.lock();
897            let base = Utc::now();
898            for i in 0..MAX_WINDOWS {
899                let key = format!("w{i:03}");
900                windows.insert(
901                    key.clone(),
902                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
903                );
904            }
905        }
906        // A new key at the cap displaces exactly the longest-silent entry.
907        reg.register(register_request("fresh", None, "/tmp/f"));
908        let windows = reg.lock();
909        assert_eq!(windows.len(), MAX_WINDOWS);
910        assert!(windows.contains_key("fresh"));
911        assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
912        assert!(windows.contains_key("w000"));
913    }
914
915    #[test]
916    fn register_upsert_at_cap_does_not_evict() {
917        let reg = WorktreesRegistry::new();
918        {
919            let mut windows = reg.lock();
920            let base = Utc::now();
921            for i in 0..MAX_WINDOWS {
922                let key = format!("w{i:03}");
923                windows.insert(
924                    key.clone(),
925                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
926                );
927            }
928        }
929        // Re-registering an existing key is an upsert: nothing is displaced,
930        // not even the oldest entry.
931        let oldest = format!("w{:03}", MAX_WINDOWS - 1);
932        reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
933        let windows = reg.lock();
934        assert_eq!(windows.len(), MAX_WINDOWS);
935        assert!(windows.contains_key(&oldest));
936        assert!(windows.contains_key("w000"));
937    }
938
939    #[test]
940    fn sorted_entries_orders_by_repo_then_key() {
941        let now = Utc::now();
942        let mut windows = HashMap::new();
943        for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
944            windows.insert(
945                key.to_string(),
946                WindowEntry {
947                    key: key.to_string(),
948                    folders: vec![],
949                    repo: Some(repo.to_string()),
950                    title: None,
951                    pid: None,
952                    last_seen: now,
953                },
954            );
955        }
956        let entries = sorted_entries(&windows);
957        let ordered: Vec<(&str, &str)> = entries
958            .iter()
959            .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
960            .collect();
961        assert_eq!(
962            ordered,
963            vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
964        );
965    }
966
967    #[test]
968    fn default_constructs_an_empty_registry() {
969        let reg = WorktreesRegistry::default();
970        assert!(reg.lock().is_empty());
971    }
972
973    // --- Change-notify for the push subscription (#1267) --------------------
974
975    #[test]
976    fn subscribe_changes_starts_seen_and_register_bumps() {
977        let reg = WorktreesRegistry::new();
978        let mut rx = reg.subscribe_changes();
979        // A fresh receiver has the current version already marked seen.
980        assert!(!rx.has_changed().unwrap());
981        // A register changes the visible set → the receiver observes a new value.
982        reg.register(register_request("w1", None, "/tmp/a"));
983        assert!(rx.has_changed().unwrap(), "register should bump");
984        // Marking it seen clears the pending change.
985        rx.borrow_and_update();
986        assert!(!rx.has_changed().unwrap());
987    }
988
989    #[test]
990    fn unregister_bumps_only_when_it_removes() {
991        let reg = WorktreesRegistry::new();
992        reg.register(register_request("w1", None, "/tmp/a"));
993        // Subscribe *after* the register so its bump is already seen.
994        let rx = reg.subscribe_changes();
995        // Removing a missing key changes nothing (and reaps nothing) → no bump.
996        assert!(!reg.unregister("ghost"));
997        assert!(
998            !rx.has_changed().unwrap(),
999            "a no-op unregister must not bump"
1000        );
1001        // Removing a present key bumps.
1002        assert!(reg.unregister("w1"));
1003        assert!(
1004            rx.has_changed().unwrap(),
1005            "a removing unregister should bump"
1006        );
1007    }
1008
1009    #[test]
1010    fn change_generation_advances_only_on_a_visible_change() {
1011        let reg = WorktreesRegistry::new();
1012        let g0 = reg.change_generation();
1013        // A no-op (heartbeat of an unknown key) leaves the generation untouched.
1014        assert!(!reg.heartbeat("ghost"));
1015        assert_eq!(
1016            reg.change_generation(),
1017            g0,
1018            "a no-op must not advance the generation"
1019        );
1020        // A register changes the visible set → the generation advances, so a
1021        // cache keyed on it rebuilds.
1022        reg.register(register_request("w1", None, "/tmp/a"));
1023        assert_ne!(
1024            reg.change_generation(),
1025            g0,
1026            "a register should advance the generation"
1027        );
1028    }
1029
1030    #[test]
1031    fn heartbeat_bumps_only_when_it_reaps() {
1032        let reg = WorktreesRegistry::new();
1033        reg.register(register_request("w1", None, "/tmp/a"));
1034        let rx = reg.subscribe_changes();
1035        // A plain heartbeat refreshes liveness but changes no visible state.
1036        assert!(reg.heartbeat("w1"));
1037        assert!(!rx.has_changed().unwrap(), "a pure heartbeat must not bump");
1038        // Seed a stale sibling directly; a heartbeat that reaps it *does* bump.
1039        {
1040            let mut windows = reg.lock();
1041            windows.insert(
1042                "stale".to_string(),
1043                entry_at("stale", Utc::now() - chrono::Duration::seconds(120)),
1044            );
1045        }
1046        assert!(reg.heartbeat("w1"));
1047        assert!(
1048            rx.has_changed().unwrap(),
1049            "a heartbeat that reaps a stale sibling should bump"
1050        );
1051    }
1052
1053    // --- Close-pending directive (#1277) -----------------------------------
1054
1055    #[test]
1056    fn close_pending_is_taken_once_then_cleared() {
1057        let reg = WorktreesRegistry::new();
1058        // No directive by default.
1059        assert!(!reg.take_close_pending("w1"));
1060        // Marked → the first take observes it, the next does not (fires once).
1061        reg.mark_close_pending("w1");
1062        assert!(reg.take_close_pending("w1"));
1063        assert!(!reg.take_close_pending("w1"));
1064    }
1065
1066    #[test]
1067    fn unregister_clears_a_pending_close_directive() {
1068        let reg = WorktreesRegistry::new();
1069        reg.register(register_request("w1", None, "/tmp/a"));
1070        reg.mark_close_pending("w1");
1071        // Unregistering the window drops any pending directive with it.
1072        assert!(reg.unregister("w1"));
1073        assert!(!reg.take_close_pending("w1"));
1074    }
1075
1076    // --- Reload-pending directive (#1417) ----------------------------------
1077
1078    #[test]
1079    fn reload_pending_is_taken_once_then_cleared() {
1080        let reg = WorktreesRegistry::new();
1081        // No directive by default.
1082        assert!(!reg.take_reload_pending("w1"));
1083        // Marked → the first take observes it, the next does not (fires once).
1084        reg.mark_reload_pending("w1");
1085        assert!(reg.take_reload_pending("w1"));
1086        assert!(!reg.take_reload_pending("w1"));
1087    }
1088
1089    #[test]
1090    fn unregister_clears_a_pending_reload_directive() {
1091        let reg = WorktreesRegistry::new();
1092        reg.register(register_request("w1", None, "/tmp/a"));
1093        reg.mark_reload_pending("w1");
1094        // Unregistering the window drops any pending directive with it.
1095        assert!(reg.unregister("w1"));
1096        assert!(!reg.take_reload_pending("w1"));
1097    }
1098
1099    #[test]
1100    fn close_and_reload_directives_are_independent() {
1101        let reg = WorktreesRegistry::new();
1102        // Separate sets: marking one must not set or consume the other, so the
1103        // heartbeat can surface both fields and the companion pick a winner.
1104        reg.mark_reload_pending("w1");
1105        assert!(!reg.take_close_pending("w1"));
1106        reg.mark_close_pending("w1");
1107        assert!(reg.take_close_pending("w1"));
1108        assert!(reg.take_reload_pending("w1"));
1109        // Each key is tracked on its own.
1110        reg.mark_reload_pending("w1");
1111        assert!(!reg.take_reload_pending("w2"));
1112        assert!(reg.take_reload_pending("w1"));
1113    }
1114
1115    #[test]
1116    fn marking_a_reload_does_not_bump() {
1117        let reg = WorktreesRegistry::new();
1118        reg.register(register_request("w1", None, "/tmp/a"));
1119        let rx = reg.subscribe_changes();
1120        // A directive is not consumer-visible state — it never reaches a tree
1121        // snapshot — so marking one must not push a redundant frame to every
1122        // subscriber. Same rule as the close directive.
1123        reg.mark_reload_pending("w1");
1124        assert!(!rx.has_changed().unwrap(), "marking a reload must not bump");
1125        assert!(reg.take_reload_pending("w1"));
1126        assert!(!rx.has_changed().unwrap(), "taking a reload must not bump");
1127    }
1128
1129    // --- Rebasing set (#1415) ----------------------------------------------
1130
1131    #[test]
1132    fn rebasing_marks_and_clears_the_named_paths() {
1133        let reg = WorktreesRegistry::new();
1134        let a = PathBuf::from("/tmp/a");
1135        let b = PathBuf::from("/tmp/b");
1136        assert!(
1137            reg.rebasing_paths().is_empty(),
1138            "nothing rebases by default"
1139        );
1140
1141        assert!(reg.mark_rebasing(&[a.clone(), b.clone()]));
1142        assert_eq!(reg.rebasing_paths(), [a.clone(), b.clone()].into());
1143
1144        // Clearing one leaves the other — a batch reports per worktree.
1145        assert!(reg.clear_rebasing(std::slice::from_ref(&a)));
1146        assert_eq!(reg.rebasing_paths(), [b.clone()].into());
1147        assert!(reg.clear_rebasing(&[b]));
1148        assert!(reg.rebasing_paths().is_empty());
1149    }
1150
1151    #[test]
1152    fn rebasing_bumps_only_on_a_real_change() {
1153        // Load-bearing: an unconditional bump would defeat the server's snapshot
1154        // diff and re-push a `tree` frame to every window on every tick.
1155        let reg = WorktreesRegistry::new();
1156        let path = PathBuf::from("/tmp/a");
1157
1158        let mut rx = reg.subscribe_changes();
1159        assert!(reg.mark_rebasing(std::slice::from_ref(&path)));
1160        assert!(rx.has_changed().unwrap(), "the first mark bumps");
1161        let _ = rx.borrow_and_update();
1162
1163        assert!(
1164            !reg.mark_rebasing(std::slice::from_ref(&path)),
1165            "re-marking an already-rebasing path is not a change"
1166        );
1167        assert!(!rx.has_changed().unwrap(), "a no-op mark must not bump");
1168
1169        assert!(
1170            !reg.clear_rebasing(&[PathBuf::from("/tmp/never-marked")]),
1171            "clearing an unmarked path is not a change"
1172        );
1173        assert!(!rx.has_changed().unwrap(), "a no-op clear must not bump");
1174
1175        assert!(reg.clear_rebasing(&[path]));
1176        assert!(rx.has_changed().unwrap(), "a real clear bumps");
1177    }
1178
1179    // --- Show/hide-closed toggle (#1301) -----------------------------------
1180
1181    #[test]
1182    fn show_closed_defaults_to_true() {
1183        let reg = WorktreesRegistry::new();
1184        assert!(reg.show_closed(), "default is show all");
1185    }
1186
1187    #[test]
1188    fn set_show_closed_reports_change_and_is_idempotent() {
1189        let reg = WorktreesRegistry::new();
1190        // Flipping to a new value reports a change and is observable.
1191        assert!(reg.set_show_closed(false));
1192        assert!(!reg.show_closed());
1193        // Setting the same value again is a no-op (no change reported).
1194        assert!(!reg.set_show_closed(false));
1195        // Flipping back reports a change again.
1196        assert!(reg.set_show_closed(true));
1197        assert!(reg.show_closed());
1198    }
1199
1200    #[test]
1201    fn set_show_closed_bumps_only_on_change() {
1202        let reg = WorktreesRegistry::new();
1203        let rx = reg.subscribe_changes();
1204        // A no-op set (already the default) does not wake subscribers.
1205        assert!(!reg.set_show_closed(true));
1206        assert!(
1207            !rx.has_changed().unwrap(),
1208            "a no-op toggle must not bump the change-notify"
1209        );
1210        // A real flip bumps so subscribers re-push a snapshot with the new value.
1211        assert!(reg.set_show_closed(false));
1212        assert!(
1213            rx.has_changed().unwrap(),
1214            "flipping the toggle should bump the change-notify"
1215        );
1216    }
1217
1218    #[test]
1219    fn polling_defaults_off_for_an_untoggled_repo() {
1220        let reg = WorktreesRegistry::new();
1221        // #1376: the whole point — a repo the user has never enabled is not polled.
1222        assert!(!reg.is_polling_enabled("rust-works", "omni-dev"));
1223        assert!(reg.enabled_polling_repos().is_empty());
1224        assert!(reg.polling_snapshot().is_empty());
1225    }
1226
1227    #[test]
1228    fn set_polling_leases_and_disables() {
1229        let reg = WorktreesRegistry::new();
1230        // Enabling a fresh repo reports a change and leases it live.
1231        assert!(reg.set_polling("rust-works", "omni-dev", true));
1232        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1233        // Re-enabling refreshes the lease — the map changes (new expiry).
1234        assert!(reg.set_polling("rust-works", "omni-dev", true));
1235        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1236        // Disabling reports a change and clears it.
1237        assert!(reg.set_polling("rust-works", "omni-dev", false));
1238        assert!(!reg.is_polling_enabled("rust-works", "omni-dev"));
1239        // Disabling an already-disabled repo is a no-op.
1240        assert!(!reg.set_polling("rust-works", "omni-dev", false));
1241    }
1242
1243    #[test]
1244    fn polling_lease_auto_expires() {
1245        // The 15-minute lease (#1376): an enabled repo drops out once its lease
1246        // elapses, reaped on the next read — no background timer.
1247        let reg = WorktreesRegistry::new();
1248        reg.set_polling("rust-works", "omni-dev", true);
1249        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1250        // Force the lease into the past (as if 15 min elapsed).
1251        reg.set_polling_expiry(
1252            "rust-works",
1253            "omni-dev",
1254            Utc::now() - ChronoDuration::seconds(1),
1255        );
1256        assert!(
1257            !reg.is_polling_enabled("rust-works", "omni-dev"),
1258            "an expired lease reads as disabled"
1259        );
1260        // The read paths reap it, so it is gone from the snapshot entirely.
1261        assert!(reg.enabled_polling_repos().is_empty());
1262        assert!(reg.polling_snapshot().is_empty());
1263        // A tiny real TTL expires on its own after a short sleep.
1264        let short = WorktreesRegistry::with_poll_ttl(Duration::from_millis(30));
1265        short.set_polling("o", "n", true);
1266        assert!(short.is_polling_enabled("o", "n"));
1267        std::thread::sleep(Duration::from_millis(60));
1268        assert!(!short.is_polling_enabled("o", "n"));
1269    }
1270
1271    #[test]
1272    fn set_polling_bumps_only_on_an_effective_flip() {
1273        let reg = WorktreesRegistry::new();
1274        let rx = reg.subscribe_changes();
1275        // A no-op (disabling an already-off repo) does not wake subscribers.
1276        assert!(!reg.set_polling("o", "n", false));
1277        assert!(
1278            !rx.has_changed().unwrap(),
1279            "a no-op poll toggle must not bump the change-notify"
1280        );
1281        // A real enable flips off→on and bumps so every window recolors.
1282        assert!(reg.set_polling("o", "n", true));
1283        assert!(
1284            rx.has_changed().unwrap(),
1285            "enabling a repo should bump the change-notify"
1286        );
1287    }
1288
1289    #[test]
1290    fn refreshing_a_live_lease_does_not_bump() {
1291        // A lease refresh (already on, still on) persists a new expiry but does
1292        // not change the visible state, so it must not wake every window.
1293        let reg = WorktreesRegistry::new();
1294        reg.set_polling("o", "n", true);
1295        let rx = reg.subscribe_changes();
1296        assert!(
1297            reg.set_polling("o", "n", true),
1298            "re-enabling refreshes the lease (map changed → persist)"
1299        );
1300        assert!(
1301            !rx.has_changed().unwrap(),
1302            "refreshing a live lease must not bump — the visible state is unchanged"
1303        );
1304    }
1305
1306    #[test]
1307    fn seed_polling_loads_leases_and_drops_expired() {
1308        let reg = WorktreesRegistry::new();
1309        reg.set_polling("a", "z", true);
1310        let future = Utc::now() + ChronoDuration::minutes(10);
1311        let past = Utc::now() - ChronoDuration::minutes(1);
1312        // Seeding (the startup load) replaces wholesale, dropping the prior entry
1313        // and any already-expired lease from the file.
1314        reg.seed_polling([
1315            ("rust-works/omni-dev".to_string(), future),
1316            ("acme/widgets".to_string(), future),
1317            ("stale/repo".to_string(), past),
1318        ]);
1319        assert!(!reg.is_polling_enabled("a", "z"));
1320        assert!(reg.is_polling_enabled("rust-works", "omni-dev"));
1321        assert!(
1322            !reg.is_polling_enabled("stale", "repo"),
1323            "expired lease dropped"
1324        );
1325        // The persisted form is deterministic (sorted) and carries the expiries.
1326        let snap = reg.polling_snapshot();
1327        assert_eq!(
1328            snap.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>(),
1329            vec![
1330                "acme/widgets".to_string(),
1331                "rust-works/omni-dev".to_string()
1332            ]
1333        );
1334        assert!(snap.iter().all(|(_, expiry)| *expiry == future));
1335    }
1336}