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::{Mutex, MutexGuard, PoisonError};
24use std::time::Duration;
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use tokio::sync::watch;
29
30/// How long a window may go silent before it ages out of the registry. Three
31/// missed ~10s heartbeats; a window that crashed without firing `unregister`
32/// disappears on the next read. The resident process is what makes this
33/// liveness correct — a flat shared file could not reap stale entries.
34const DEFAULT_TTL: Duration = Duration::from_secs(30);
35
36/// Ceiling on live registry entries, so a misbehaving companion flooding
37/// `register` with distinct keys cannot grow daemon memory faster than the TTL
38/// reaps it (#1140). Far above any real window count; when a new key would
39/// exceed it, the longest-silent entry is evicted instead of rejecting the
40/// request — an evicted live window self-heals via the `heartbeat` →
41/// `{known: false}` → re-register path, so `register` stays infallible for the
42/// companion.
43const MAX_WINDOWS: usize = 256;
44
45/// A `register` request from a companion extension.
46///
47/// The companion owns its `key` (a per-`activate()` UUID) so the registry never
48/// has to reason about whether `vscode.env.sessionId` is unique per window;
49/// everything else is best-effort metadata.
50#[derive(Debug, Clone, Deserialize)]
51pub struct RegisterRequest {
52    /// Stable per-window identity, generated by the companion on activation.
53    pub key: String,
54    /// Absolute paths of the window's workspace folders.
55    #[serde(default)]
56    pub folders: Vec<PathBuf>,
57    /// Repository root or name, when the window has one.
58    #[serde(default)]
59    pub repo: Option<String>,
60    /// The window title, for display.
61    #[serde(default)]
62    pub title: Option<String>,
63    /// The reporting extension-host process id.
64    #[serde(default)]
65    pub pid: Option<u32>,
66}
67
68/// One open window's live registration. Serialized verbatim into `list` /
69/// `status` payloads; consumers compute age from `last_seen` (RFC 3339).
70#[derive(Debug, Clone, Serialize)]
71pub struct WindowEntry {
72    /// The companion-owned per-window key.
73    pub key: String,
74    /// Absolute workspace-folder paths.
75    pub folders: Vec<PathBuf>,
76    /// Repository root or name, if reported.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub repo: Option<String>,
79    /// Window title, if reported.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub title: Option<String>,
82    /// Reporting extension-host pid, if reported.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub pid: Option<u32>,
85    /// When the registry last heard from this window (register or heartbeat).
86    pub last_seen: DateTime<Utc>,
87}
88
89/// The cross-window worktree registry: the in-memory, TTL-reaped set of open
90/// windows. Hosted by
91/// [`WorktreesService`](crate::daemon::services::worktrees::WorktreesService).
92pub struct WorktreesRegistry {
93    /// Open windows keyed by their companion-owned `key`.
94    windows: Mutex<HashMap<String, WindowEntry>>,
95    /// How long an entry survives without a heartbeat.
96    ttl: Duration,
97    /// A monotonically-bumped version counter, incremented whenever the visible
98    /// set of windows changes (a `register`, a removing `unregister`, or a
99    /// mutation-driven reap that drops a stale entry). A push-subscription
100    /// consumer holds a [`watch::Receiver`] from
101    /// [`subscribe_changes`](Self::subscribe_changes) and wakes on each bump to
102    /// re-snapshot (#1267). The counter's *value* is immaterial —
103    /// only that it changed — so a burst coalesces into one wake and the
104    /// subscriber diffs the resulting snapshot to suppress duplicate frames.
105    ///
106    /// `watch` needs no runtime and never blocks, so it fits this engine's
107    /// no-async-setup posture; every [`bump`](Self::bump) happens *after* the map
108    /// guard is dropped, so the `std::Mutex`-never-across-`.await` rule is intact
109    /// (and the watch's own internal lock is never nested under the map lock).
110    changes: watch::Sender<u64>,
111    /// Window keys with a pending "close yourself" directive, set by the
112    /// `close` op (#1277) when a cross-window close must reach a window the
113    /// daemon can only *reply* to, never call. Each key is surfaced — and
114    /// cleared — on that window's next `heartbeat` (the `known:false →
115    /// re-register` precedent, riding the same reply). In-memory only, like the
116    /// window map: a daemon restart drops any pending directive (the close op
117    /// aborts and the user retries — an accepted failure mode). Behind its own
118    /// `Mutex`, taken independently of the window map's, so neither nests.
119    close_pending: Mutex<HashSet<String>>,
120}
121
122impl WorktreesRegistry {
123    /// Creates the registry with the default liveness TTL. Cheap — no I/O.
124    #[must_use]
125    pub fn new() -> Self {
126        Self {
127            windows: Mutex::new(HashMap::new()),
128            ttl: DEFAULT_TTL,
129            changes: watch::channel(0).0,
130            close_pending: Mutex::new(HashSet::new()),
131        }
132    }
133
134    /// A change-notification receiver for the push subscription: it observes a
135    /// new value each time the visible window set changes (see [`changes`] and
136    /// [`bump`]). Created with the current version already marked seen, so the
137    /// first [`watch::Receiver::changed`] resolves on the *next* change — the
138    /// subscriber sends its own initial snapshot up front and then waits for
139    /// deltas (#1267).
140    ///
141    /// [`changes`]: Self::changes
142    /// [`bump`]: Self::bump
143    #[must_use]
144    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
145        self.changes.subscribe()
146    }
147
148    /// Signals subscribers that the visible window set changed. Non-blocking and
149    /// runtime-free; called only *after* the map guard is released so the two
150    /// locks never nest. A send never fails here (the sender is owned by the
151    /// registry, which outlives every receiver, and `send_modify` bumps even
152    /// with no receivers).
153    fn bump(&self) {
154        self.changes.send_modify(|v| *v = v.wrapping_add(1));
155    }
156
157    /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
158    /// critical section must not wedge the whole registry).
159    fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
160        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
161    }
162
163    /// Records (upserts) a window registration. Reaps stale entries first, then
164    /// — only when a genuinely new key would grow the map past [`MAX_WINDOWS`] —
165    /// evicts the longest-silent entry. Infallible: an upsert never evicts, and
166    /// callers validate the `key` before reaching here.
167    pub fn register(&self, req: RegisterRequest) {
168        let now = Utc::now();
169        {
170            let mut windows = self.lock();
171            reap(&mut windows, self.ttl, now);
172            // Upserts never evict; only a genuinely new key can grow the map, and
173            // never past MAX_WINDOWS.
174            if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
175                evict_oldest(&mut windows);
176            }
177            windows.insert(
178                req.key.clone(),
179                WindowEntry {
180                    key: req.key,
181                    folders: req.folders,
182                    repo: req.repo,
183                    title: req.title,
184                    pid: req.pid,
185                    last_seen: now,
186                },
187            );
188        }
189        // Always bump: a register is infrequent (once per companion `activate()`,
190        // not per heartbeat) and may add or alter a window's folders/repo. A
191        // no-op re-register with identical data is harmless — the subscriber
192        // diffs the snapshot and suppresses the duplicate frame.
193        self.bump();
194    }
195
196    /// Refreshes a window's liveness. Returns whether the key was known: a
197    /// `false` tells a window that started before the daemon — or survived a
198    /// daemon restart — to re-`register`, since the registry is in-memory and
199    /// has no record of it.
200    pub fn heartbeat(&self, key: &str) -> bool {
201        let now = Utc::now();
202        let (known, reaped) = {
203            let mut windows = self.lock();
204            let reaped = reap(&mut windows, self.ttl, now);
205            let known = match windows.get_mut(key) {
206                Some(entry) => {
207                    entry.last_seen = now;
208                    true
209                }
210                None => false,
211            };
212            (known, reaped)
213        };
214        // A heartbeat is frequent (~every 10 s per window); a pure liveness
215        // refresh does not change the visible set, so bump *only* when this
216        // heartbeat's inline reap actually aged a stale sibling out.
217        if reaped > 0 {
218            self.bump();
219        }
220        known
221    }
222
223    /// Drops a window's registration. Returns whether an entry was present.
224    pub fn unregister(&self, key: &str) -> bool {
225        let now = Utc::now();
226        let (removed, reaped) = {
227            let mut windows = self.lock();
228            let removed = windows.remove(key).is_some();
229            let reaped = reap(&mut windows, self.ttl, now);
230            (removed, reaped)
231        };
232        // The window is gone; any close directive for it is fulfilled or moot.
233        // (Keys are per-`activate()` UUIDs, never reused, so a stale directive
234        // would only ever leak a little memory — but clearing keeps it tidy.)
235        self.take_close_pending(key);
236        if removed || reaped > 0 {
237            self.bump();
238        }
239        removed
240    }
241
242    /// Records a pending "close yourself" directive for `key`, to be surfaced on
243    /// that window's next `heartbeat`. Set by the `close` op when signalling a
244    /// window it can only reply to. Idempotent; infallible.
245    pub fn mark_close_pending(&self, key: &str) {
246        self.close_pending
247            .lock()
248            .unwrap_or_else(PoisonError::into_inner)
249            .insert(key.to_string());
250    }
251
252    /// Takes (returns and clears) `key`'s pending close directive. Called on
253    /// each `heartbeat` so the directive fires exactly once; a `false` means no
254    /// close is pending.
255    pub fn take_close_pending(&self, key: &str) -> bool {
256        self.close_pending
257            .lock()
258            .unwrap_or_else(PoisonError::into_inner)
259            .remove(key)
260    }
261
262    /// Reaps stale entries, then returns the live set sorted for deterministic
263    /// output. Holds the lock only for pure-CPU work.
264    ///
265    /// Like the other reads ([`open_folders`](Self::open_folders),
266    /// [`first_folder`](Self::first_folder)) this reaps but never
267    /// [`bump`](Self::bump)s: the only observer of a read-path reap is the push
268    /// subscription's own re-snapshot (or `status`/`menu`), and the
269    /// subscription's periodic tick already re-samples read-only staleness — so
270    /// bumping here would only make the subscription wake itself (#1267).
271    pub fn list(&self) -> Vec<WindowEntry> {
272        let now = Utc::now();
273        let mut windows = self.lock();
274        reap(&mut windows, self.ttl, now);
275        sorted_entries(&windows)
276    }
277
278    /// The first workspace folder of a still-live window, if it has one. Used by
279    /// the tray "focus" action to resolve a key to a folder to open. Does not
280    /// reap — a menu action races the reaper either way, and the caller handles
281    /// a `None` (the window may have closed).
282    pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
283        let windows = self.lock();
284        windows.get(key).and_then(|e| e.folders.first().cloned())
285    }
286
287    /// Snapshots the distinct workspace folders across all live windows — the
288    /// seed set the adapter resolves to repositories (each folder → its git
289    /// common dir → repo root) to enumerate every worktree per repo (#1265).
290    ///
291    /// Reaps stale entries first, then returns the folders sorted and
292    /// deduplicated. Like [`list`](Self::list) it is pure CPU under the lock:
293    /// the git resolution the "distinct repos" derivation needs is disk I/O and
294    /// stays in the adapter, off the registry lock, honouring the
295    /// `Mutex`-never-across-`.await` invariant.
296    pub fn open_folders(&self) -> Vec<PathBuf> {
297        let now = Utc::now();
298        let mut windows = self.lock();
299        reap(&mut windows, self.ttl, now);
300        let mut folders: Vec<PathBuf> = windows
301            .values()
302            .flat_map(|e| e.folders.iter().cloned())
303            .collect();
304        folders.sort();
305        folders.dedup();
306        folders
307    }
308}
309
310impl Default for WorktreesRegistry {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316/// Removes entries last seen longer than `ttl` ago, returning how many were
317/// dropped. Pure CPU; the caller holds the registry lock but never `.await`s
318/// while holding it. The count lets a *mutation* path
319/// ([`register`](WorktreesRegistry::register) et al.) decide whether to
320/// [`bump`](WorktreesRegistry::bump) the change-notify; read paths ignore it (see
321/// [`list`](WorktreesRegistry::list)).
322fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) -> usize {
323    let max_age = ttl.as_secs() as i64;
324    let before = windows.len();
325    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
326    before - windows.len()
327}
328
329/// Removes the entry with the oldest `last_seen` (ties broken by lowest key
330/// for determinism). Called when a `register` of a new key would grow the
331/// registry past [`MAX_WINDOWS`]. Pure CPU under the registry lock, like
332/// [`reap`].
333fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
334    let oldest = windows
335        .values()
336        .min_by(|a, b| {
337            a.last_seen
338                .cmp(&b.last_seen)
339                .then_with(|| a.key.cmp(&b.key))
340        })
341        .map(|e| e.key.clone());
342    if let Some(key) = oldest {
343        windows.remove(&key);
344    }
345}
346
347/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
348/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
349fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
350    let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
351    entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
352    entries
353}
354
355#[cfg(test)]
356#[allow(clippy::unwrap_used, clippy::expect_used)]
357mod tests {
358    use super::*;
359
360    fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
361        RegisterRequest {
362            key: key.to_string(),
363            folders: vec![PathBuf::from(folder)],
364            repo: repo.map(str::to_string),
365            title: Some(format!("{key}-title")),
366            pid: Some(1234),
367        }
368    }
369
370    #[test]
371    fn list_is_empty_initially() {
372        let reg = WorktreesRegistry::new();
373        assert!(reg.list().is_empty());
374    }
375
376    #[test]
377    fn register_then_list_round_trips() {
378        let reg = WorktreesRegistry::new();
379        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
380        let windows = reg.list();
381        assert_eq!(windows.len(), 1);
382        assert_eq!(windows[0].key, "w1");
383        assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
384    }
385
386    #[test]
387    fn register_is_idempotent_upsert() {
388        let reg = WorktreesRegistry::new();
389        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
390        // Re-registering the same key updates rather than duplicates.
391        reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
392        let windows = reg.list();
393        assert_eq!(windows.len(), 1);
394        assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
395    }
396
397    #[test]
398    fn heartbeat_reports_known_and_unknown() {
399        let reg = WorktreesRegistry::new();
400        // Unknown before registration: the window must re-register.
401        assert!(!reg.heartbeat("w1"));
402        reg.register(register_request("w1", None, "/tmp/a"));
403        assert!(reg.heartbeat("w1"));
404    }
405
406    #[test]
407    fn unregister_removes() {
408        let reg = WorktreesRegistry::new();
409        reg.register(register_request("w1", None, "/tmp/a"));
410        assert!(reg.unregister("w1"));
411        // Removing again is a no-op.
412        assert!(!reg.unregister("w1"));
413    }
414
415    #[test]
416    fn first_folder_returns_first_folder_or_none() {
417        let reg = WorktreesRegistry::new();
418        // No such key.
419        assert!(reg.first_folder("missing").is_none());
420        reg.register(register_request("w1", None, "/tmp/a"));
421        assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
422        // A folderless window resolves to None rather than a folder.
423        reg.register(RegisterRequest {
424            key: "w2".to_string(),
425            folders: vec![],
426            repo: None,
427            title: None,
428            pid: None,
429        });
430        assert!(reg.first_folder("w2").is_none());
431    }
432
433    #[test]
434    fn open_folders_dedups_and_sorts_across_windows() {
435        let reg = WorktreesRegistry::new();
436        assert!(reg.open_folders().is_empty());
437        // Two windows sharing a folder, plus a multi-folder window: the shared
438        // path collapses and the result is sorted.
439        reg.register(register_request("w1", Some("repo-a"), "/tmp/shared"));
440        reg.register(RegisterRequest {
441            key: "w2".to_string(),
442            folders: vec![PathBuf::from("/tmp/shared"), PathBuf::from("/tmp/b")],
443            repo: Some("repo-a".to_string()),
444            title: None,
445            pid: None,
446        });
447        reg.register(register_request("w3", Some("repo-b"), "/tmp/a"));
448        assert_eq!(
449            reg.open_folders(),
450            vec![
451                PathBuf::from("/tmp/a"),
452                PathBuf::from("/tmp/b"),
453                PathBuf::from("/tmp/shared"),
454            ]
455        );
456    }
457
458    #[test]
459    fn open_folders_reaps_stale_windows() {
460        let reg = WorktreesRegistry::new();
461        {
462            let mut windows = reg.lock();
463            windows.insert(
464                "fresh".to_string(),
465                WindowEntry {
466                    key: "fresh".to_string(),
467                    folders: vec![PathBuf::from("/tmp/fresh")],
468                    repo: None,
469                    title: None,
470                    pid: None,
471                    last_seen: Utc::now(),
472                },
473            );
474            windows.insert(
475                "stale".to_string(),
476                WindowEntry {
477                    key: "stale".to_string(),
478                    folders: vec![PathBuf::from("/tmp/stale")],
479                    repo: None,
480                    title: None,
481                    pid: None,
482                    last_seen: Utc::now() - chrono::Duration::seconds(120),
483                },
484            );
485        }
486        // The stale window's folder is reaped out of the snapshot.
487        assert_eq!(reg.open_folders(), vec![PathBuf::from("/tmp/fresh")]);
488    }
489
490    #[test]
491    fn reap_evicts_only_stale_entries() {
492        let now = Utc::now();
493        let mut windows = HashMap::new();
494        windows.insert(
495            "fresh".to_string(),
496            WindowEntry {
497                key: "fresh".to_string(),
498                folders: vec![],
499                repo: None,
500                title: None,
501                pid: None,
502                last_seen: now - chrono::Duration::seconds(5),
503            },
504        );
505        windows.insert(
506            "stale".to_string(),
507            WindowEntry {
508                key: "stale".to_string(),
509                folders: vec![],
510                repo: None,
511                title: None,
512                pid: None,
513                last_seen: now - chrono::Duration::seconds(120),
514            },
515        );
516        reap(&mut windows, DEFAULT_TTL, now);
517        assert!(windows.contains_key("fresh"));
518        assert!(!windows.contains_key("stale"));
519    }
520
521    /// A minimal entry for cap/eviction tests; only `key` and `last_seen`
522    /// participate in eviction order.
523    fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
524        WindowEntry {
525            key: key.to_string(),
526            folders: vec![],
527            repo: None,
528            title: None,
529            pid: None,
530            last_seen,
531        }
532    }
533
534    #[test]
535    fn evict_oldest_removes_oldest_with_key_tiebreak() {
536        let now = Utc::now();
537        let mut windows = HashMap::new();
538        windows.insert("young".to_string(), entry_at("young", now));
539        windows.insert(
540            "old-b".to_string(),
541            entry_at("old-b", now - chrono::Duration::seconds(10)),
542        );
543        windows.insert(
544            "old-a".to_string(),
545            entry_at("old-a", now - chrono::Duration::seconds(10)),
546        );
547        // Oldest `last_seen` is shared by two entries; the lowest key loses.
548        evict_oldest(&mut windows);
549        assert!(!windows.contains_key("old-a"));
550        assert!(windows.contains_key("old-b"));
551        assert!(windows.contains_key("young"));
552        // Empty map is a no-op rather than a panic.
553        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
554        evict_oldest(&mut empty);
555        assert!(empty.is_empty());
556    }
557
558    #[test]
559    fn register_at_cap_evicts_only_the_oldest() {
560        let reg = WorktreesRegistry::new();
561        // Seed a full registry directly (registering 256 times would work too,
562        // but sub-second timestamps may tie; explicit timestamps make the
563        // highest-numbered key unambiguously the oldest).
564        {
565            let mut windows = reg.lock();
566            let base = Utc::now();
567            for i in 0..MAX_WINDOWS {
568                let key = format!("w{i:03}");
569                windows.insert(
570                    key.clone(),
571                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
572                );
573            }
574        }
575        // A new key at the cap displaces exactly the longest-silent entry.
576        reg.register(register_request("fresh", None, "/tmp/f"));
577        let windows = reg.lock();
578        assert_eq!(windows.len(), MAX_WINDOWS);
579        assert!(windows.contains_key("fresh"));
580        assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
581        assert!(windows.contains_key("w000"));
582    }
583
584    #[test]
585    fn register_upsert_at_cap_does_not_evict() {
586        let reg = WorktreesRegistry::new();
587        {
588            let mut windows = reg.lock();
589            let base = Utc::now();
590            for i in 0..MAX_WINDOWS {
591                let key = format!("w{i:03}");
592                windows.insert(
593                    key.clone(),
594                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
595                );
596            }
597        }
598        // Re-registering an existing key is an upsert: nothing is displaced,
599        // not even the oldest entry.
600        let oldest = format!("w{:03}", MAX_WINDOWS - 1);
601        reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
602        let windows = reg.lock();
603        assert_eq!(windows.len(), MAX_WINDOWS);
604        assert!(windows.contains_key(&oldest));
605        assert!(windows.contains_key("w000"));
606    }
607
608    #[test]
609    fn sorted_entries_orders_by_repo_then_key() {
610        let now = Utc::now();
611        let mut windows = HashMap::new();
612        for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
613            windows.insert(
614                key.to_string(),
615                WindowEntry {
616                    key: key.to_string(),
617                    folders: vec![],
618                    repo: Some(repo.to_string()),
619                    title: None,
620                    pid: None,
621                    last_seen: now,
622                },
623            );
624        }
625        let entries = sorted_entries(&windows);
626        let ordered: Vec<(&str, &str)> = entries
627            .iter()
628            .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
629            .collect();
630        assert_eq!(
631            ordered,
632            vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
633        );
634    }
635
636    #[test]
637    fn default_constructs_an_empty_registry() {
638        let reg = WorktreesRegistry::default();
639        assert!(reg.lock().is_empty());
640    }
641
642    // --- Change-notify for the push subscription (#1267) --------------------
643
644    #[test]
645    fn subscribe_changes_starts_seen_and_register_bumps() {
646        let reg = WorktreesRegistry::new();
647        let mut rx = reg.subscribe_changes();
648        // A fresh receiver has the current version already marked seen.
649        assert!(!rx.has_changed().unwrap());
650        // A register changes the visible set → the receiver observes a new value.
651        reg.register(register_request("w1", None, "/tmp/a"));
652        assert!(rx.has_changed().unwrap(), "register should bump");
653        // Marking it seen clears the pending change.
654        rx.borrow_and_update();
655        assert!(!rx.has_changed().unwrap());
656    }
657
658    #[test]
659    fn unregister_bumps_only_when_it_removes() {
660        let reg = WorktreesRegistry::new();
661        reg.register(register_request("w1", None, "/tmp/a"));
662        // Subscribe *after* the register so its bump is already seen.
663        let rx = reg.subscribe_changes();
664        // Removing a missing key changes nothing (and reaps nothing) → no bump.
665        assert!(!reg.unregister("ghost"));
666        assert!(
667            !rx.has_changed().unwrap(),
668            "a no-op unregister must not bump"
669        );
670        // Removing a present key bumps.
671        assert!(reg.unregister("w1"));
672        assert!(
673            rx.has_changed().unwrap(),
674            "a removing unregister should bump"
675        );
676    }
677
678    #[test]
679    fn heartbeat_bumps_only_when_it_reaps() {
680        let reg = WorktreesRegistry::new();
681        reg.register(register_request("w1", None, "/tmp/a"));
682        let rx = reg.subscribe_changes();
683        // A plain heartbeat refreshes liveness but changes no visible state.
684        assert!(reg.heartbeat("w1"));
685        assert!(!rx.has_changed().unwrap(), "a pure heartbeat must not bump");
686        // Seed a stale sibling directly; a heartbeat that reaps it *does* bump.
687        {
688            let mut windows = reg.lock();
689            windows.insert(
690                "stale".to_string(),
691                entry_at("stale", Utc::now() - chrono::Duration::seconds(120)),
692            );
693        }
694        assert!(reg.heartbeat("w1"));
695        assert!(
696            rx.has_changed().unwrap(),
697            "a heartbeat that reaps a stale sibling should bump"
698        );
699    }
700
701    // --- Close-pending directive (#1277) -----------------------------------
702
703    #[test]
704    fn close_pending_is_taken_once_then_cleared() {
705        let reg = WorktreesRegistry::new();
706        // No directive by default.
707        assert!(!reg.take_close_pending("w1"));
708        // Marked → the first take observes it, the next does not (fires once).
709        reg.mark_close_pending("w1");
710        assert!(reg.take_close_pending("w1"));
711        assert!(!reg.take_close_pending("w1"));
712    }
713
714    #[test]
715    fn unregister_clears_a_pending_close_directive() {
716        let reg = WorktreesRegistry::new();
717        reg.register(register_request("w1", None, "/tmp/a"));
718        reg.mark_close_pending("w1");
719        // Unregistering the window drops any pending directive with it.
720        assert!(reg.unregister("w1"));
721        assert!(!reg.take_close_pending("w1"));
722    }
723}