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(®, "fresh", Duration::ZERO);
937 insert_silent(®, "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(®, "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(®, "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(®, "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(®, "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(®, "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(®, "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(®, "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(®, "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(®, 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(®, 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(®, 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(®, 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(®, 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(®, "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}