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