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