vcs_watch/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-watch` — filesystem-watch a git/jj repository and emit typed state-change
4//! events.
5//!
6//! A [`RepoWatcher`] watches a repository's `.git`/`.jj` state directory (and,
7//! optionally, the working tree), **debounces** the burst of writes a VCS
8//! operation makes, **re-queries** the repo state through
9//! [`vcs-core`](vcs_core)'s batched [`snapshot`](vcs_core::Repo::snapshot), and
10//! **diffs** it against the previous state to yield typed [`RepoEvent`]s. Each
11//! settled change arrives as a [`RepoChange`] carrying both the new
12//! [`RepoSnapshot`] (to render a prompt/status line) and the deltas (to react).
13//! It's the foundation for prompts, status bars, TUIs, and repo daemons.
14//!
15//! Re-query-and-diff — rather than interpreting raw filesystem events — is what
16//! makes it robust: git's ref temp-file renames, `index.lock` churn, and reflog
17//! noise all just coalesce into one "re-check the settled state" instead of being
18//! (mis)read as events. Noise that doesn't move observable state emits nothing,
19//! and every emission carries the true current state, so a stray event can't
20//! desync the consumer.
21//!
22//! # The surface
23//!
24//! - **[`RepoWatcher`]** — a live watch over one repository. Start it with
25//! [`RepoWatcher::watch`] (defaults) or the [`Builder`]; drop it to stop the OS
26//! watch and the background task.
27//! - **[`Builder`]** ([`RepoWatcher::builder`]) — set the watch scope and timing,
28//! then [`build`](Builder::build): [`working_tree`](Builder::working_tree) to
29//! also watch the tree recursively, [`debounce`](Builder::debounce) (the quiet
30//! window), [`max_wait`](Builder::max_wait) (the re-query ceiling under a
31//! continuous stream), [`requery_timeout`](Builder::requery_timeout) (the
32//! per-re-query deadline). The [`DEFAULT_REQUERY_TIMEOUT`] et al. name the
33//! defaults.
34//! - **[`RepoEvent`]** — one typed delta, derived by diffing two snapshots:
35//! [`HeadMoved`](RepoEvent::HeadMoved),
36//! [`BranchSwitched`](RepoEvent::BranchSwitched),
37//! [`BranchCreated`](RepoEvent::BranchCreated) /
38//! [`BranchDeleted`](RepoEvent::BranchDeleted),
39//! [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged), and the
40//! upstream/ahead-behind/operation/conflict variants (`#[non_exhaustive]`).
41//! - **[`RepoChange`]** — a settled change: the fresh [`RepoSnapshot`] (render a
42//! status line off it) plus the non-empty `events` vec (react to it).
43//! - **Consumption** — pull changes with [`recv`](RepoWatcher::recv)
44//! (`Option<RepoChange>`; `None` once dropped), or, under the **`stream`**
45//! feature, poll the watcher as a `futures_core::Stream`. Both pull from the
46//! same channel and advance [`current`](RepoWatcher::current), the last-pulled
47//! snapshot.
48//! - **[`WatcherStats`]** ([`stats`](RepoWatcher::stats)) — lock-free health
49//! counters (re-queries run, changes emitted, skips, and the last skip's
50//! [`WatcherErrorKind`]). Climbing [`skipped`](WatcherStats::skipped) with flat
51//! [`changes`](WatcherStats::changes) means a wedged repo — poll it from a
52//! health check rather than inferring health from event silence.
53//!
54//! # Recipes
55//!
56//! Watch with the defaults and react to each settled change:
57//!
58//! ```no_run
59//! use vcs_core::Repo;
60//! use vcs_watch::RepoWatcher;
61//! # async fn run() -> vcs_watch::Result<()> {
62//! let repo = Repo::open(".")?;
63//! let mut watcher = RepoWatcher::watch(repo).await?;
64//! while let Some(change) = watcher.recv().await {
65//! for event in &change.events {
66//! println!("{event:?}");
67//! }
68//! // `change.snapshot` is the fresh full state — render a status line off it.
69//! }
70//! # Ok(()) }
71//! ```
72//!
73//! Under the **`stream`** feature the watcher *is* a `futures_core::Stream`,
74//! so it drops into stream combinators and `tokio::select!` directly (needs
75//! `futures`/`tokio-stream`'s `StreamExt` in scope):
76//!
77//! ```ignore
78//! use futures::StreamExt;
79//! use vcs_core::Repo;
80//! use vcs_watch::RepoWatcher;
81//! # async fn run() -> vcs_watch::Result<()> {
82//! let repo = Repo::open(".")?;
83//! let mut watcher = RepoWatcher::watch(repo).await?;
84//! while let Some(change) = watcher.next().await {
85//! println!("{} event(s)", change.events.len());
86//! }
87//! # Ok(()) }
88//! ```
89//!
90//! **Runtime:** unlike the rest of the toolkit (which hides tokio behind
91//! `processkit`), `vcs-watch` uses **tokio at runtime** — the watch task and the
92//! debounce timer run on the caller's tokio runtime, so build/await it from
93//! within one.
94//!
95//! # Testing
96//!
97//! The debounce → ceiling → re-query pipeline is a free function over injected
98//! seams, so it is exercised hermetically on a **paused clock** (no real
99//! filesystem or sleeps); a consumer's own watch code tests the same way it tests
100//! any [`vcs-core`](vcs_core) consumer — build the [`Repo`](vcs_core::Repo) over a
101//! fake runner (processkit's `ScriptedRunner`) so the re-query returns canned
102//! state. See
103//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
104//!
105//! # In-depth guide
106//!
107//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
108//! from `docs/`. See the [`guide`] module.
109
110use std::path::{Path, PathBuf};
111use std::sync::Arc;
112use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
113use std::time::Duration;
114
115use notify::{RecursiveMode, Watcher};
116use tokio::sync::mpsc;
117use vcs_core::{BackendKind, VcsRepo};
118
119mod error;
120mod event;
121
122pub use error::{Error, Result};
123pub use event::{RepoChange, RepoEvent};
124// Re-export the snapshot types a consumer reads off a `RepoChange`, so depending
125// on `vcs-watch` alone suffices.
126pub use vcs_core::{OperationState, RepoSnapshot};
127// Re-export `processkit` so a `vcs-watch`-only consumer can name the
128// `Error::processkit_error()` return type without a direct `processkit`
129// dependency (mirrors `vcs_core::processkit` / `vcs_forge::processkit`).
130pub use processkit;
131
132/// Default quiet window: a re-query fires once the watched dir has been silent
133/// for this long after the last event.
134const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(250);
135/// Default ceiling: even under a continuous stream of events, re-query at least
136/// this often (so a long bulk operation still reports progress).
137const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(1);
138/// Upper clamp for [`max_wait`](Builder::max_wait) when it is turned into an
139/// `Instant` deadline. `Instant + Duration` *panics* on overflow, and `max_wait`
140/// is caller-settable with no bound (`.max_wait(Duration::MAX)` is a natural
141/// "disable the ceiling" idiom), so cap the addend at an effectively-unbounded
142/// one year — a huge value then disables the ceiling instead of panicking the
143/// spawned watch loop, which would drop the output channel and kill the watcher
144/// silently.
145const MAX_WAIT_CEILING: Duration = Duration::from_secs(60 * 60 * 24 * 365);
146/// Default deadline on a single re-query (`snapshot` + branch list): a wedged
147/// command (e.g. a held `index.lock` with no client timeout configured) is
148/// killed and skipped instead of stalling the watch loop forever.
149pub const DEFAULT_REQUERY_TIMEOUT: Duration = Duration::from_secs(30);
150/// Bounded output channel: a slow consumer applies backpressure (the loop pauses
151/// re-querying), and pending filesystem signals coalesce into one catch-up query.
152const OUTPUT_CAPACITY: usize = 64;
153
154/// The timing/capacity knobs the background loop runs under — bundled so the
155/// loop signature stays small and the hermetic tests can vary them (notably
156/// `output_capacity`, which the backpressure test shrinks to 1).
157struct LoopConfig {
158 debounce: Duration,
159 max_wait: Duration,
160 /// `None` disables the per-re-query deadline.
161 requery_timeout: Option<Duration>,
162 output_capacity: usize,
163}
164
165/// Builder for a [`RepoWatcher`] — set the watch scope and debounce timing, then
166/// [`build`](Builder::build).
167pub struct Builder {
168 repo: Box<dyn VcsRepo>,
169 working_tree: bool,
170 debounce: Duration,
171 max_wait: Duration,
172 requery_timeout: Option<Duration>,
173}
174
175impl Builder {
176 /// Also watch the **working tree** recursively, so a bare unstaged edit
177 /// (`vim file`) fires [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged)
178 /// immediately. Off by default (only the `.git`/`.jj` state dir is watched,
179 /// which catches an unstaged edit once it touches the index / a jj snapshot).
180 ///
181 /// Note: `notify` is `.gitignore`-unaware, so this also watches ignored and
182 /// build directories — heavier on a large tree.
183 pub fn working_tree(mut self, yes: bool) -> Self {
184 self.working_tree = yes;
185 self
186 }
187
188 /// The quiet window: re-query once the watched dir has been silent this long
189 /// after the last event (default 250 ms). Coalesces an operation's write
190 /// burst into one re-check.
191 pub fn debounce(mut self, window: Duration) -> Self {
192 self.debounce = window;
193 self
194 }
195
196 /// The ceiling on how long a continuous event stream defers the re-query
197 /// (default 1 s) — a long bulk operation still reports at this cadence.
198 pub fn max_wait(mut self, ceiling: Duration) -> Self {
199 self.max_wait = ceiling;
200 self
201 }
202
203 /// Deadline on a single re-query (the `snapshot` + branch-list pair), default
204 /// [`DEFAULT_REQUERY_TIMEOUT`] (30 s); `None` disables it. Orthogonal to
205 /// [`max_wait`](Self::max_wait): that bounds how long signals may *defer* a
206 /// re-query, this bounds how long one re-query may *run*. On overrun the
207 /// spawned commands are killed (kill-on-drop) and the re-query is skipped as
208 /// transient — the next filesystem event re-checks.
209 ///
210 /// It **also bounds the startup baseline** captured by [`build`](Self::build): a
211 /// baseline that overruns fails `build()` with a transient `Io` `TimedOut`
212 /// (`Error::is_transient()`), rather than hanging the caller — so a wedged repo
213 /// can't stall `build()` any more than it can stall the loop.
214 ///
215 /// Note: on a very large repository a *cold-cache* `git status` (first run
216 /// after a `gc`, or on a slow disk) can legitimately exceed the 30 s default
217 /// — raise it (or pass `None`) there; a watcher whose every re-query is
218 /// being killed shows up as climbing [`WatcherStats::skipped`] with flat
219 /// `changes`.
220 pub fn requery_timeout(mut self, timeout: Option<Duration>) -> Self {
221 self.requery_timeout = timeout;
222 self
223 }
224
225 /// Start watching. Captures the baseline state, registers the filesystem
226 /// watch, and spawns the background re-query task on the current tokio
227 /// runtime.
228 ///
229 /// The baseline capture is bounded by [`requery_timeout`](Self::requery_timeout),
230 /// so on a wedged repo `build()` returns a transient `Io` `TimedOut`
231 /// (`Error::is_transient()`) instead of hanging at startup — retry, or raise the
232 /// timeout.
233 pub async fn build(self) -> Result<RepoWatcher> {
234 let root = self.repo.root().to_path_buf();
235 // The dirs whose writes mean "re-check": the `.git`/`.jj` state dir, plus
236 // — for a linked git worktree — the *shared* git dir it points at via
237 // `commondir` (where `refs/heads/*` and `packed-refs` actually live, so
238 // branch create/delete is seen). See `state_dirs`.
239 let state_dirs = state_dirs(self.repo.kind(), &root)?;
240
241 // Bridge: notify's callback thread pushes a unit "something changed" signal
242 // per event; the debounce loop drains it. The channel is **capacity 1** and
243 // the callback uses `try_send`, so a burst *coalesces* into a single pending
244 // signal (extra events while one is pending are dropped — the loop re-queries
245 // the full snapshot anyway, so no state is lost). This bounds memory: an
246 // unbounded channel would grow without limit if the consumer stopped draining
247 // the output while a filesystem storm churned (R2). Build the watcher and
248 // register paths *before* the baseline snapshot, so a change racing the
249 // baseline is queued, not lost.
250 let (raw_tx, raw_rx) = mpsc::channel::<()>(1);
251 let stats = Arc::new(StatsInner::default());
252 let cb_stats = Arc::clone(&stats);
253 let mut watcher =
254 notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
255 // A backend **error** (e.g. the watched dir was removed — on Windows
256 // `ReadDirectoryChangesW` fails the watch) is counted so a consumer can
257 // notice via `stats().watch_errors` and rebuild; we can't auto-re-register
258 // from here. Either way, re-query: content is irrelevant, any event (or
259 // error) just means "re-check". `send` fails only after the loop ends.
260 if res.is_err() {
261 cb_stats.note_watch_error();
262 }
263 // `try_send` on the capacity-1 channel: succeeds when no signal is
264 // pending, drops (coalesces) when one already is. Never blocks the
265 // notify callback thread; `Err` (full or loop-ended) is intentionally
266 // ignored.
267 let _ = raw_tx.try_send(());
268 })?;
269 if self.working_tree {
270 watcher.watch(&root, RecursiveMode::Recursive)?;
271 // A worktree gitlink puts the real (private and shared) git dirs
272 // outside `root`; cover any not already under the recursive root watch.
273 for dir in &state_dirs {
274 if !dir.starts_with(&root) {
275 watcher.watch(dir, RecursiveMode::Recursive)?;
276 }
277 }
278 } else {
279 for dir in &state_dirs {
280 watcher.watch(dir, RecursiveMode::Recursive)?;
281 }
282 }
283
284 // Capture the baseline under the same `requery_timeout` deadline the loop
285 // applies to every re-query (R4) — otherwise a snapshot that wedges (a hung
286 // fsmonitor, a network filesystem, a held jj lock) on a `Repo` built without
287 // its own `default_timeout` would hang `build()` at startup, the very failure
288 // the loop-side deadline exists to prevent.
289 let (snapshot, branches) = capture_baseline(&*self.repo, self.requery_timeout).await?;
290 let baseline = snapshot.clone();
291 let prev = event::WatchState::from_snapshot(&snapshot, branches);
292
293 let config = LoopConfig {
294 debounce: self.debounce,
295 max_wait: self.max_wait,
296 requery_timeout: self.requery_timeout,
297 output_capacity: OUTPUT_CAPACITY,
298 };
299 let (out_tx, out_rx) = mpsc::channel::<RepoChange>(config.output_capacity);
300 let task = tokio::spawn(watch_loop(
301 self.repo,
302 raw_rx,
303 out_tx,
304 prev,
305 config,
306 Arc::clone(&stats),
307 ));
308
309 Ok(RepoWatcher {
310 rx: out_rx,
311 current: baseline,
312 stats,
313 _watcher: watcher,
314 task,
315 })
316 }
317}
318
319// --- Watcher health counters --------------------------------------------------
320
321/// What the last skipped re-query failed on (see [`WatcherStats::last_error`]).
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323#[non_exhaustive]
324pub enum WatcherErrorKind {
325 /// The snapshot re-query returned an error (e.g. a transiently held lock).
326 Snapshot,
327 /// The branch-list re-query returned an error.
328 Branches,
329 /// The re-query exceeded [`Builder::requery_timeout`] and was killed.
330 Timeout,
331}
332
333/// A cheap point-in-time copy of the watcher's health counters — see
334/// [`RepoWatcher::stats`]. Lets a long-running consumer notice a watcher that is
335/// silently skipping re-queries (e.g. a permanently wedged repository) instead
336/// of inferring health from event silence.
337#[derive(Debug, Clone, Copy)]
338#[non_exhaustive]
339pub struct WatcherStats {
340 /// Re-query attempts started (settled bursts that reached the query step).
341 pub requeries: u64,
342 /// Re-queries that emitted a [`RepoChange`] (the rest found no difference).
343 pub changes: u64,
344 /// Re-queries skipped — transient query failures plus deadline overruns.
345 pub skipped: u64,
346 /// What the most recent skip failed on; `None` when nothing was ever skipped.
347 pub last_error: Option<WatcherErrorKind>,
348 /// Filesystem-watch **errors** reported by the OS backend (via `notify`). A
349 /// non-zero — especially *climbing* — count means the underlying watch is
350 /// failing: most often the watched `.git`/`.jj` directory was **removed and
351 /// re-created** (a re-clone / `jj git init`), which invalidates the OS watch on
352 /// the old directory. The watcher does **not** auto-re-register in that case, so
353 /// it can silently stop delivering changes; treat a rising `watch_errors` as
354 /// "rebuild the watcher" (drop it and call [`RepoWatcher::watch`] again).
355 ///
356 /// **Best-effort, platform-dependent.** It is reliable on **Windows**, where
357 /// removing the watched directory fails `ReadDirectoryChangesW` and `notify`
358 /// reports an error. On **Linux** (`inotify`) a removed/re-created directory may
359 /// surface as an ordinary event or a silent watch teardown rather than an error,
360 /// so `watch_errors` can stay `0` even as the watcher goes deaf — don't rely on
361 /// it as the sole liveness signal there.
362 pub watch_errors: u64,
363}
364
365/// Lock-free counter cell shared between the loop and `stats()` readers. Relaxed
366/// ordering is enough: the counters are independent monotonic telemetry, not a
367/// synchronization protocol.
368#[derive(Default)]
369struct StatsInner {
370 requeries: AtomicU64,
371 changes: AtomicU64,
372 skipped: AtomicU64,
373 /// 0 = none, else `WatcherErrorKind as u8 + 1`.
374 last_error: AtomicU8,
375 watch_errors: AtomicU64,
376}
377
378impl StatsInner {
379 fn note_requery(&self) {
380 self.requeries.fetch_add(1, Ordering::Relaxed);
381 }
382
383 fn note_change(&self) {
384 self.changes.fetch_add(1, Ordering::Relaxed);
385 }
386
387 fn note_watch_error(&self) {
388 self.watch_errors.fetch_add(1, Ordering::Relaxed);
389 }
390
391 fn note_skip(&self, kind: WatcherErrorKind) {
392 self.skipped.fetch_add(1, Ordering::Relaxed);
393 let code = match kind {
394 WatcherErrorKind::Snapshot => 1,
395 WatcherErrorKind::Branches => 2,
396 WatcherErrorKind::Timeout => 3,
397 };
398 self.last_error.store(code, Ordering::Relaxed);
399 }
400
401 fn snapshot(&self) -> WatcherStats {
402 let last_error = match self.last_error.load(Ordering::Relaxed) {
403 1 => Some(WatcherErrorKind::Snapshot),
404 2 => Some(WatcherErrorKind::Branches),
405 3 => Some(WatcherErrorKind::Timeout),
406 _ => None,
407 };
408 WatcherStats {
409 requeries: self.requeries.load(Ordering::Relaxed),
410 changes: self.changes.load(Ordering::Relaxed),
411 skipped: self.skipped.load(Ordering::Relaxed),
412 last_error,
413 watch_errors: self.watch_errors.load(Ordering::Relaxed),
414 }
415 }
416}
417
418/// A live watch over a repository, yielding [`RepoChange`]s as the repo's state
419/// changes. Dropping it stops the filesystem watch and the background task.
420pub struct RepoWatcher {
421 rx: mpsc::Receiver<RepoChange>,
422 current: RepoSnapshot,
423 stats: Arc<StatsInner>,
424 // Held to keep the OS watch alive; dropping it ends the watch (and the loop).
425 _watcher: notify::RecommendedWatcher,
426 task: tokio::task::JoinHandle<()>,
427}
428
429impl RepoWatcher {
430 /// A builder over `repo` (any [`VcsRepo`] — e.g. a [`vcs_core::Repo`]).
431 pub fn builder(repo: impl VcsRepo + 'static) -> Builder {
432 Builder {
433 repo: Box::new(repo),
434 working_tree: false,
435 debounce: DEFAULT_DEBOUNCE,
436 max_wait: DEFAULT_MAX_WAIT,
437 requery_timeout: Some(DEFAULT_REQUERY_TIMEOUT),
438 }
439 }
440
441 /// Start watching `repo` with the defaults (state dir only, 250 ms debounce).
442 pub async fn watch(repo: impl VcsRepo + 'static) -> Result<RepoWatcher> {
443 Self::builder(repo).build().await
444 }
445
446 /// Await the next settled change. Returns `None` once the watcher is dropped
447 /// or its background task ends.
448 pub async fn recv(&mut self) -> Option<RepoChange> {
449 let change = self.rx.recv().await?;
450 self.current = change.snapshot.clone();
451 Some(change)
452 }
453
454 /// The most recent known snapshot — the baseline captured at
455 /// [`build`](Builder::build), then the snapshot from each [`recv`](Self::recv).
456 /// It advances **only when you call [`recv`](Self::recv)**, so it is as fresh
457 /// as your last `recv`, not a live view.
458 pub fn current(&self) -> &RepoSnapshot {
459 &self.current
460 }
461
462 /// The watcher's health counters (re-queries run / changes emitted / skips,
463 /// what the last skip failed on, and OS-watch errors). Cheap relaxed-atomic
464 /// reads — poll it from a health check or log it periodically; a climbing
465 /// [`skipped`](WatcherStats::skipped) with flat
466 /// [`changes`](WatcherStats::changes) means the repository is wedged, and a
467 /// non-zero [`watch_errors`](WatcherStats::watch_errors) means the OS watch is
468 /// failing (e.g. the watched dir was re-created) — rebuild the watcher.
469 pub fn stats(&self) -> WatcherStats {
470 self.stats.snapshot()
471 }
472}
473
474/// Yields each settled [`RepoChange`] as a stream item (the `stream` feature).
475/// Equivalent to looping [`recv`](RepoWatcher::recv) — both pull from the same
476/// underlying channel (an item is delivered to whichever is polled first, never
477/// duplicated) and both advance [`current`](RepoWatcher::current).
478#[cfg(feature = "stream")]
479#[cfg_attr(docsrs, doc(cfg(feature = "stream")))]
480impl futures_core::Stream for RepoWatcher {
481 type Item = RepoChange;
482
483 fn poll_next(
484 self: std::pin::Pin<&mut Self>,
485 cx: &mut std::task::Context<'_>,
486 ) -> std::task::Poll<Option<RepoChange>> {
487 // All fields are Unpin, so the watcher is Unpin and get_mut is sound.
488 let this = self.get_mut();
489 match this.rx.poll_recv(cx) {
490 std::task::Poll::Ready(Some(change)) => {
491 this.current = change.snapshot.clone();
492 std::task::Poll::Ready(Some(change))
493 }
494 other => other,
495 }
496 }
497}
498
499impl Drop for RepoWatcher {
500 fn drop(&mut self) {
501 // The dropped `_watcher` already closes the signal channel (ending the
502 // loop); abort is belt-and-braces for prompt teardown.
503 self.task.abort();
504 }
505}
506
507/// Capture the startup baseline (snapshot + local branches) under `requery_timeout`
508/// (R4). A `Some(limit)` bounds the whole capture with `tokio::time::timeout`; on
509/// expiry it returns [`Error::Io`] `TimedOut` and dropping the future kills the
510/// underlying process (kill-on-drop), exactly as the loop does for a re-query — so a
511/// wedged snapshot can't hang `build()` forever. `None` leaves it unbounded.
512async fn capture_baseline(
513 repo: &dyn VcsRepo,
514 requery_timeout: Option<Duration>,
515) -> Result<(vcs_core::RepoSnapshot, Vec<String>)> {
516 let query = async {
517 let snapshot = repo.snapshot().await?;
518 let branches = repo.local_branches().await?;
519 Ok::<_, Error>((snapshot, branches))
520 };
521 match requery_timeout {
522 Some(limit) => match tokio::time::timeout(limit, query).await {
523 Ok(result) => result,
524 Err(_elapsed) => Err(Error::Io(std::io::Error::new(
525 std::io::ErrorKind::TimedOut,
526 format!("baseline snapshot exceeded the {limit:?} requery_timeout"),
527 ))),
528 },
529 None => query.await,
530 }
531}
532
533/// The background loop: coalesce a burst of filesystem signals, re-query the
534/// settled state, diff against the previous, and emit a [`RepoChange`] when
535/// anything changed.
536///
537/// A free function over plain channels + a [`VcsRepo`] (not a method) on
538/// purpose: the hermetic pipeline tests below drive it directly — a fake signal
539/// channel in, a `ScriptedRunner`-backed `Repo`, a paused tokio clock — pinning
540/// the debounce/ceiling/skip semantics without any real filesystem or process.
541async fn watch_loop(
542 repo: Box<dyn VcsRepo>,
543 mut raw_rx: mpsc::Receiver<()>,
544 out_tx: mpsc::Sender<RepoChange>,
545 mut prev: event::WatchState,
546 config: LoopConfig,
547 stats: Arc<StatsInner>,
548) {
549 loop {
550 // Block until the first signal (or exit when the watcher is dropped).
551 if raw_rx.recv().await.is_none() {
552 return;
553 }
554 // Coalesce the burst: reset a `debounce` quiet-timer on every new signal,
555 // but never wait past `max_wait` total. The dedicated `sleep_until` arm
556 // makes the ceiling exact (it fires even when no further signal arrives);
557 // the in-arm deadline check guards against a signal stream so dense that
558 // the `biased` select never polls the timer arms.
559 drain(&mut raw_rx);
560 // Clamp the addend: `Instant + Duration` panics on overflow, and a huge
561 // caller `max_wait` (e.g. `Duration::MAX`) must disable the ceiling, not
562 // crash the loop. See [`MAX_WAIT_CEILING`].
563 let deadline = tokio::time::Instant::now() + config.max_wait.min(MAX_WAIT_CEILING);
564 loop {
565 tokio::select! {
566 biased;
567 sig = raw_rx.recv() => {
568 if sig.is_none() {
569 return; // watcher dropped mid-burst
570 }
571 // Collapse the queued backlog: under a notify storm each
572 // queued unit signal would otherwise cost a select iteration
573 // that re-creates BOTH timer futures — a burst is one
574 // "still busy" observation, not N.
575 drain(&mut raw_rx);
576 if tokio::time::Instant::now() >= deadline {
577 break; // ceiling reached — re-query now
578 }
579 // else: another event — loop resets the quiet timer
580 }
581 _ = tokio::time::sleep_until(deadline) => break, // ceiling
582 _ = tokio::time::sleep(config.debounce) => break, // settled
583 }
584 }
585
586 // Re-query the settled state, bounded by the configured deadline — a
587 // wedged command (a held `index.lock` on a client with no timeout) must
588 // not stall the watch forever. Dropping the overrun future kills the
589 // spawned process tree (processkit's kill-on-drop group), so a timed-out
590 // query leaves no orphan. Failures and overruns are *transient skips*:
591 // counted, traced, and re-checked on the next filesystem event.
592 stats.note_requery();
593 let requery = async {
594 let snapshot = repo
595 .snapshot()
596 .await
597 .map_err(|e| (WatcherErrorKind::Snapshot, e))?;
598 let branches = repo
599 .local_branches()
600 .await
601 .map_err(|e| (WatcherErrorKind::Branches, e))?;
602 Ok::<_, (WatcherErrorKind, vcs_core::Error)>((snapshot, branches))
603 };
604 let outcome = match config.requery_timeout {
605 Some(limit) => match tokio::time::timeout(limit, requery).await {
606 Ok(result) => result,
607 Err(_elapsed) => {
608 stats.note_skip(WatcherErrorKind::Timeout);
609 #[cfg(feature = "tracing")]
610 tracing::debug!(
611 timeout = ?limit,
612 "vcs-watch: re-query exceeded its deadline; killed and skipped"
613 );
614 continue;
615 }
616 },
617 None => requery.await,
618 };
619 let (snapshot, branches) = match outcome {
620 Ok(pair) => pair,
621 Err((kind, _e)) => {
622 stats.note_skip(kind);
623 #[cfg(feature = "tracing")]
624 tracing::debug!(error = %_e, "vcs-watch: re-query failed; skipping");
625 continue;
626 }
627 };
628
629 let next = event::WatchState::from_snapshot(&snapshot, branches);
630 let events = event::diff(&prev, &next);
631 prev = next;
632 if events.is_empty() {
633 continue;
634 }
635 if out_tx.send(RepoChange { snapshot, events }).await.is_err() {
636 return; // receiver dropped — stop
637 }
638 stats.note_change();
639 }
640}
641
642/// Drop every already-queued unit signal — the burst is one observation. Leaves
643/// channel-closed detection to the caller's next `recv` (a drained-empty and a
644/// closed channel both just stop yielding here).
645fn drain(raw_rx: &mut mpsc::Receiver<()>) {
646 while raw_rx.try_recv().is_ok() {}
647}
648
649/// The directories to watch for a backend, deduplicated. Normally one — the
650/// `.git`/`.jj` state dir (see [`state_dir`]) — but a **linked git worktree** has
651/// two: its private gitdir (HEAD/index/logs) *and* the shared git dir it points
652/// at via `commondir` (`refs/heads/*` and `packed-refs`, where branch
653/// create/delete actually lands). Watching only the private dir would miss every
654/// `BranchCreated`/`BranchDeleted` on a worktree, since the shared dir is a
655/// *sibling*, not nested under it (see [`common_dir`]).
656///
657/// Overlapping watches are harmless — the re-query+debounce coalesces duplicate
658/// signals — but we drop a second dir whose normalized path equals the first, so
659/// `notify` isn't asked to watch the same path twice.
660fn state_dirs(kind: BackendKind, root: &Path) -> Result<Vec<PathBuf>> {
661 let state_dir = state_dir(kind, root)?;
662 let mut dirs = vec![state_dir.clone()];
663 if let Some(shared) = common_dir(&state_dir)
664 && normalize(&shared) != normalize(&state_dir)
665 {
666 dirs.push(shared);
667 }
668 Ok(dirs)
669}
670
671/// The directory to watch for a backend: `.jj` for jj, `.git` for git. A
672/// worktree's `.git` is a gitlink *file* (`gitdir: <path>`); resolve it to the
673/// real git directory. Best-effort — falls back to the `.git` path itself.
674fn state_dir(kind: BackendKind, root: &Path) -> Result<PathBuf> {
675 match kind {
676 BackendKind::Jj => Ok(root.join(".jj")),
677 BackendKind::Git => {
678 let dot_git = root.join(".git");
679 if dot_git.is_file() {
680 let content = std::fs::read_to_string(&dot_git)?;
681 if let Some(rest) = content.trim().strip_prefix("gitdir:") {
682 let p = PathBuf::from(rest.trim());
683 return Ok(if p.is_absolute() { p } else { root.join(p) });
684 }
685 }
686 Ok(dot_git)
687 }
688 // `BackendKind` is `#[non_exhaustive]`; for an unknown future backend
689 // watch the repo root itself — coarser, but it can't miss the state dir.
690 _ => Ok(root.to_path_buf()),
691 }
692}
693
694/// The **shared** git directory for a linked worktree, or `None` for a plain
695/// repo. A linked worktree's resolved gitdir holds a `commondir` file whose
696/// content is a path (typically relative, e.g. `../..`) to the shared `.git` —
697/// where `refs/heads/*` and `packed-refs` live. We join it to the gitdir and
698/// resolve `..` (lexically, matching the no-canonicalize style of [`state_dir`],
699/// so the registered path stays plain rather than a Windows `\\?\` verbatim one).
700/// A plain repo has no `commondir` file, so this is `None` and behaviour is
701/// unchanged.
702fn common_dir(state_dir: &Path) -> Option<PathBuf> {
703 let commondir = state_dir.join("commondir");
704 let content = std::fs::read_to_string(&commondir).ok()?;
705 let rel = content.trim();
706 if rel.is_empty() {
707 return None;
708 }
709 let p = PathBuf::from(rel);
710 let joined = if p.is_absolute() {
711 p
712 } else {
713 state_dir.join(p)
714 };
715 Some(lexically_normalized(&joined))
716}
717
718/// Resolve `.`/`..` components without touching the filesystem, keeping the path
719/// in its original (non-verbatim) form — `commondir`'s `../..` plus a Windows
720/// gitdir would otherwise leave literal `..` segments in the watched path.
721fn lexically_normalized(p: &Path) -> PathBuf {
722 use std::path::Component;
723 let mut out = PathBuf::new();
724 for comp in p.components() {
725 match comp {
726 Component::ParentDir => {
727 // Pop a real segment; keep a leading `..` that can't be resolved.
728 if !out.pop() {
729 out.push(comp);
730 }
731 }
732 Component::CurDir => {}
733 other => out.push(other),
734 }
735 }
736 out
737}
738
739/// Canonicalize for comparison and strip the Windows verbatim prefix (`\\?\…`,
740/// which `canonicalize` adds), so two spellings of the same dir dedup. Mirrors
741/// `vcs-core`'s path-compare normalization; falls back to the input when the path
742/// can't be canonicalized (then equal paths still compare equal byte-for-byte).
743fn normalize(p: &Path) -> PathBuf {
744 let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
745 #[cfg(windows)]
746 {
747 let s = canonical.to_string_lossy();
748 if let Some(rest) = s.strip_prefix(r"\\?\")
749 && !rest.starts_with("UNC\\")
750 {
751 return PathBuf::from(rest.to_string());
752 }
753 }
754 canonical
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use std::sync::atomic::{AtomicU64, Ordering};
761
762 static COUNTER: AtomicU64 = AtomicU64::new(0);
763
764 /// A unique, self-cleaning temp dir (no temp-dir crate needed for these
765 /// hermetic helper tests — pid + counter keeps parallel tests from colliding).
766 /// `pub(crate)`: the pipeline tests below reuse it for the scripted repo's
767 /// on-disk git dir (the snapshot's MERGE_HEAD probe reads the filesystem).
768 pub(crate) struct Scratch(pub(crate) PathBuf);
769 impl Scratch {
770 pub(crate) fn new() -> Self {
771 let p = std::env::temp_dir().join(format!(
772 "vcs-watch-commondir-{}-{}",
773 std::process::id(),
774 COUNTER.fetch_add(1, Ordering::Relaxed)
775 ));
776 std::fs::create_dir_all(&p).expect("create scratch dir");
777 Scratch(p)
778 }
779 }
780 impl Drop for Scratch {
781 fn drop(&mut self) {
782 let _ = std::fs::remove_dir_all(&self.0);
783 }
784 }
785
786 // A plain (non-worktree) git dir has no `commondir` file → no shared dir, so
787 // behaviour is exactly today's single-dir watch.
788 #[test]
789 fn no_commondir_file_yields_none() {
790 let scratch = Scratch::new();
791 let git_dir = scratch.0.join(".git");
792 std::fs::create_dir_all(&git_dir).expect("mkdir .git");
793 assert_eq!(common_dir(&git_dir), None);
794 }
795
796 // A linked-worktree layout: the private gitdir holds `commondir` = `../..`
797 // (git's actual content), which must resolve to the sibling shared `.git`.
798 #[test]
799 fn relative_commondir_resolves_to_shared_git_dir() {
800 let scratch = Scratch::new();
801 let shared = scratch.0.join(".git");
802 let private = shared.join("worktrees").join("wt");
803 std::fs::create_dir_all(&private).expect("mkdir private gitdir");
804 // git writes `../..` (relative to the private dir) here.
805 std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
806
807 let resolved = common_dir(&private).expect("Some(shared dir)");
808 // `<shared>/worktrees/wt` + `../..` == `<shared>` (lexically, no `..` left).
809 assert_eq!(resolved, lexically_normalized(&shared));
810 assert!(
811 !resolved.to_string_lossy().contains(".."),
812 "the `..` segments must be resolved, got {}",
813 resolved.display()
814 );
815 }
816
817 // An absolute `commondir` (git permits it) is taken as-is.
818 #[test]
819 fn absolute_commondir_is_used_verbatim() {
820 let scratch = Scratch::new();
821 let shared = scratch.0.join("shared-git");
822 let private = scratch.0.join("private");
823 std::fs::create_dir_all(&private).expect("mkdir private");
824 std::fs::write(private.join("commondir"), format!("{}\n", shared.display()))
825 .expect("write commondir");
826
827 assert_eq!(common_dir(&private), Some(lexically_normalized(&shared)));
828 }
829
830 // `state_dirs` returns both the private and shared dirs for a worktree, and
831 // the shared dir is not the private one (so two distinct watches register).
832 #[test]
833 fn state_dirs_includes_private_and_shared_for_worktree() {
834 let scratch = Scratch::new();
835 let root = scratch.0.join("wt-worktree");
836 let shared = scratch.0.join(".git");
837 let private = shared.join("worktrees").join("wt");
838 std::fs::create_dir_all(&private).expect("mkdir private gitdir");
839 std::fs::create_dir_all(&root).expect("mkdir worktree root");
840 std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
841 // The worktree's `.git` gitlink file points at the private dir.
842 std::fs::write(
843 root.join(".git"),
844 format!("gitdir: {}\n", private.display()),
845 )
846 .expect("write gitlink");
847
848 let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
849 assert_eq!(dirs.len(), 2, "private + shared, got {dirs:?}");
850 assert_eq!(normalize(&dirs[0]), normalize(&private));
851 assert_eq!(normalize(&dirs[1]), normalize(&shared));
852 }
853
854 // When `commondir` resolves back to the state dir itself (degenerate), the
855 // duplicate is dropped — we never register the same path twice.
856 #[test]
857 fn self_referential_commondir_is_deduped() {
858 let scratch = Scratch::new();
859 let git_dir = scratch.0.join(".git");
860 std::fs::create_dir_all(&git_dir).expect("mkdir .git");
861 // `.` resolves to the dir itself.
862 std::fs::write(git_dir.join("commondir"), ".\n").expect("write commondir");
863 // The gitlink points the worktree root at this very dir.
864 let root = scratch.0.join("root");
865 std::fs::create_dir_all(&root).expect("mkdir root");
866 std::fs::write(
867 root.join(".git"),
868 format!("gitdir: {}\n", git_dir.display()),
869 )
870 .expect("write gitlink");
871
872 let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
873 assert_eq!(dirs.len(), 1, "self-reference deduped, got {dirs:?}");
874 }
875
876 // R3: verify the `watch_errors` counter→`snapshot()` plumbing (the notify
877 // callback that calls `note_watch_error` on a backend `Err` can't be driven from
878 // a unit test, so this pins the counter is wired in and stays independent).
879 #[test]
880 fn stats_counts_watch_errors_independently() {
881 let stats = StatsInner::default();
882 assert_eq!(stats.snapshot().watch_errors, 0);
883 stats.note_watch_error();
884 stats.note_watch_error();
885 let snap = stats.snapshot();
886 assert_eq!(snap.watch_errors, 2, "watch errors counted");
887 assert_eq!(
888 (snap.requeries, snap.changes, snap.skipped),
889 (0, 0, 0),
890 "other counters unaffected"
891 );
892 assert!(snap.last_error.is_none());
893 }
894}
895
896/// Hermetic tests of the debounce → ceiling → re-query → diff pipeline itself:
897/// `watch_loop` is driven directly with a fake signal channel, a
898/// `ScriptedRunner`-backed `Repo`, and a **paused tokio clock** — no real
899/// filesystem watch, no real process, no real sleeps. These pin the *loop's*
900/// timing contract; the notify→signal bridge stays covered by the `#[ignore]`
901/// integration tests (fake time says nothing about real OS event batching).
902#[cfg(test)]
903mod pipeline_tests {
904 use super::tests::Scratch;
905 use super::*;
906 use processkit::ProcessRunner;
907 use processkit::testing::{Reply, ScriptedRunner};
908 use vcs_core::Repo;
909 use vcs_core::vcs_git::Git;
910
911 /// Porcelain-v2 (NUL-separated) status output for a repo at `head`, clean.
912 fn v2(head: &str) -> String {
913 format!("# branch.oid {head}\0# branch.head main\0")
914 }
915
916 /// The exact command set one snapshot+branches re-query issues, scripted:
917 /// `status --porcelain=v2`, the `rev-parse --git-dir` probe (must point at a
918 /// real dir — the op-state probe reads `MERGE_HEAD` off the filesystem), and
919 /// `branch --no-column`.
920 fn scripted(gitdir: &Path, head: &str) -> ScriptedRunner {
921 ScriptedRunner::new()
922 .on(["git", "status"], Reply::ok(v2(head)))
923 .on(
924 ["git", "rev-parse"],
925 Reply::ok(format!("{}\n", gitdir.display())),
926 )
927 .on(["git", "branch"], Reply::ok("* main\n"))
928 }
929
930 fn scripted_repo(gitdir: &Path, head: &str) -> Box<dyn VcsRepo> {
931 Box::new(Repo::from_git(
932 "/r",
933 "/r",
934 Git::with_runner(scripted(gitdir, head)),
935 ))
936 }
937
938 /// The baseline `prev` state the loop diffs against, taken through the same
939 /// snapshot path `Builder::build` uses.
940 async fn baseline(gitdir: &Path, head: &str) -> event::WatchState {
941 let repo = scripted_repo(gitdir, head);
942 let snap = repo.snapshot().await.expect("baseline snapshot");
943 let branches = repo.local_branches().await.expect("baseline branches");
944 event::WatchState::from_snapshot(&snap, branches)
945 }
946
947 fn defaults() -> LoopConfig {
948 LoopConfig {
949 debounce: Duration::from_millis(250),
950 max_wait: Duration::from_secs(1),
951 requery_timeout: Some(Duration::from_secs(30)),
952 output_capacity: 64,
953 }
954 }
955
956 struct Harness {
957 sig: mpsc::Sender<()>,
958 out: mpsc::Receiver<RepoChange>,
959 stats: Arc<StatsInner>,
960 task: tokio::task::JoinHandle<()>,
961 }
962
963 impl Harness {
964 // Mirror the production notify callback: fire-and-forget `try_send` on the
965 // capacity-1 bridge (a pending signal coalesces the next one). `Err` (full or
966 // loop-ended) is intentionally ignored — a still-pending signal already
967 // triggers the re-query the caller wants.
968 fn signal(&self) {
969 let _ = self.sig.try_send(());
970 }
971 }
972
973 fn spawn_loop(repo: Box<dyn VcsRepo>, prev: event::WatchState, config: LoopConfig) -> Harness {
974 let (sig, raw_rx) = mpsc::channel(1);
975 let (out_tx, out) = mpsc::channel(config.output_capacity);
976 let stats = Arc::new(StatsInner::default());
977 let task = tokio::spawn(watch_loop(
978 repo,
979 raw_rx,
980 out_tx,
981 prev,
982 config,
983 Arc::clone(&stats),
984 ));
985 Harness {
986 sig,
987 out,
988 stats,
989 task,
990 }
991 }
992
993 /// Let the loop task run to a quiescent point without advancing time —
994 /// paused-clock auto-advance only triggers when every task idles on a timer,
995 /// so a bounded yield burst (never a spin-until loop) is the safe way to let
996 /// an already-runnable re-query complete.
997 async fn settle() {
998 for _ in 0..32 {
999 tokio::task::yield_now().await;
1000 }
1001 }
1002
1003 // A burst of sub-debounce signals coalesces into exactly one re-query and
1004 // one emitted change.
1005 #[tokio::test(start_paused = true)]
1006 async fn debounce_coalesces_burst() {
1007 let scratch = Scratch::new();
1008 let prev = baseline(&scratch.0, "aaa").await;
1009 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1010
1011 for _ in 0..5 {
1012 h.signal();
1013 tokio::time::advance(Duration::from_millis(10)).await;
1014 }
1015 let change = h.out.recv().await.expect("one coalesced change");
1016 assert!(
1017 change
1018 .events
1019 .iter()
1020 .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1021 "expected HeadMoved, got {:?}",
1022 change.events
1023 );
1024
1025 // Long quiet: nothing else arrives, and exactly one re-query ran.
1026 tokio::time::advance(Duration::from_secs(5)).await;
1027 settle().await;
1028 assert!(
1029 h.out.try_recv().is_err(),
1030 "burst must coalesce to one change"
1031 );
1032 let stats = h.stats.snapshot();
1033 assert_eq!((stats.requeries, stats.changes), (1, 1));
1034 }
1035
1036 // Signals arriving faster than the quiet window forever: the `max_wait`
1037 // ceiling still forces a re-query at its cadence (the dedicated
1038 // `sleep_until` arm — not just "on the next signal after the deadline").
1039 #[tokio::test(start_paused = true)]
1040 async fn max_wait_caps_continuous_signals() {
1041 let scratch = Scratch::new();
1042 let prev = baseline(&scratch.0, "aaa").await;
1043 let h_config = defaults();
1044 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, h_config);
1045
1046 // A pump that fires a signal every 100 ms — always inside the 250 ms
1047 // quiet window, so only the ceiling can break the burst.
1048 let pump_sig = h.sig.clone();
1049 let pump = tokio::spawn(async move {
1050 loop {
1051 // `try_send` mirrors the notify callback. `Full` means our previous
1052 // signal is still pending (coalesced) — keep pumping; `Closed` means
1053 // the loop ended — stop.
1054 if let Err(mpsc::error::TrySendError::Closed(())) = pump_sig.try_send(()) {
1055 return;
1056 }
1057 tokio::time::sleep(Duration::from_millis(100)).await;
1058 }
1059 });
1060
1061 let change = tokio::time::timeout(Duration::from_secs(2), h.out.recv())
1062 .await
1063 .expect("the ceiling must fire within max_wait")
1064 .expect("change");
1065 assert!(
1066 change
1067 .events
1068 .iter()
1069 .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1070 "got {:?}",
1071 change.events
1072 );
1073 pump.abort();
1074 }
1075
1076 // P1: a caller "disabling the ceiling" with `Duration::MAX` must not overflow
1077 // the `Instant + max_wait` deadline and panic the spawned loop (which would
1078 // drop the output channel and kill the watcher silently). The clamp keeps it
1079 // running; the debounce timer still fires normally.
1080 #[tokio::test(start_paused = true)]
1081 async fn max_wait_duration_max_does_not_panic_the_loop() {
1082 let scratch = Scratch::new();
1083 let prev = baseline(&scratch.0, "aaa").await;
1084 let config = LoopConfig {
1085 max_wait: Duration::MAX,
1086 ..defaults()
1087 };
1088 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, config);
1089 h.signal();
1090 tokio::time::advance(Duration::from_millis(300)).await; // past the 250 ms debounce
1091 let change = h
1092 .out
1093 .recv()
1094 .await
1095 .expect("the loop survives a Duration::MAX max_wait and still re-queries");
1096 assert!(!change.events.is_empty(), "got {:?}", change.events);
1097 }
1098
1099 // The base case: one signal, a quiet gap, one re-query.
1100 #[tokio::test(start_paused = true)]
1101 async fn quiet_gap_triggers_requery() {
1102 let scratch = Scratch::new();
1103 let prev = baseline(&scratch.0, "aaa").await;
1104 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1105
1106 h.signal();
1107 let change = h.out.recv().await.expect("change after the quiet gap");
1108 assert!(
1109 change
1110 .events
1111 .iter()
1112 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1113 );
1114 }
1115
1116 // A re-query that finds the same state emits nothing — but it *ran* (the
1117 // stats distinguish "no change" from "never re-queried").
1118 #[tokio::test(start_paused = true)]
1119 async fn no_change_yields_no_emission() {
1120 let scratch = Scratch::new();
1121 let prev = baseline(&scratch.0, "aaa").await;
1122 // Same head as the baseline → empty diff.
1123 let mut h = spawn_loop(scripted_repo(&scratch.0, "aaa"), prev, defaults());
1124
1125 h.signal();
1126 settle().await; // let the loop register its quiet timer first
1127 tokio::time::advance(Duration::from_millis(300)).await; // past debounce
1128 settle().await; // let the re-query run
1129
1130 let stats = h.stats.snapshot();
1131 assert_eq!((stats.requeries, stats.changes, stats.skipped), (1, 0, 0));
1132 assert!(
1133 h.out.try_recv().is_err(),
1134 "no events for an unchanged state"
1135 );
1136 }
1137
1138 /// Fails the first `status` call (a transiently held lock), then behaves —
1139 /// `ScriptedRunner` rules are stateless, so the two-phase behaviour needs a
1140 /// tiny stateful runner delegating to throwaway scripted ones.
1141 struct FlakyStatus {
1142 fails_left: AtomicU64,
1143 gitdir: PathBuf,
1144 head: &'static str,
1145 }
1146
1147 #[async_trait::async_trait]
1148 impl ProcessRunner for FlakyStatus {
1149 async fn output_string(
1150 &self,
1151 command: &processkit::Command,
1152 ) -> processkit::Result<processkit::ProcessResult<String>> {
1153 let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1154 if is_status && self.fails_left.load(Ordering::Relaxed) > 0 {
1155 self.fails_left.fetch_sub(1, Ordering::Relaxed);
1156 return Err(processkit::Error::Exit {
1157 program: "git".into(),
1158 code: 128,
1159 stdout: String::new(),
1160 stderr: "fatal: Unable to create '.git/index.lock'".into(),
1161 });
1162 }
1163 scripted(&self.gitdir, self.head)
1164 .output_string(command)
1165 .await
1166 }
1167 }
1168
1169 // A transient re-query failure is skipped (counted, no emission); the next
1170 // signal re-checks and recovers.
1171 #[tokio::test(start_paused = true)]
1172 async fn transient_failure_skips_then_recovers() {
1173 let scratch = Scratch::new();
1174 let prev = baseline(&scratch.0, "aaa").await;
1175 let repo = Box::new(Repo::from_git(
1176 "/r",
1177 "/r",
1178 Git::with_runner(FlakyStatus {
1179 fails_left: AtomicU64::new(1),
1180 gitdir: scratch.0.clone(),
1181 head: "bbb",
1182 }),
1183 ));
1184 let mut h = spawn_loop(repo, prev, defaults());
1185
1186 // First attempt: the snapshot fails → skip, nothing emitted.
1187 h.signal();
1188 settle().await; // loop registers the quiet timer
1189 tokio::time::advance(Duration::from_millis(300)).await;
1190 settle().await; // the (failing) re-query runs
1191 let stats = h.stats.snapshot();
1192 assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
1193 assert_eq!(stats.last_error, Some(WatcherErrorKind::Snapshot));
1194 assert!(h.out.try_recv().is_err());
1195
1196 // Second signal: the lock "cleared" — the re-query recovers and emits.
1197 h.signal();
1198 let change = h.out.recv().await.expect("recovered change");
1199 assert!(
1200 change
1201 .events
1202 .iter()
1203 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1204 );
1205 let stats = h.stats.snapshot();
1206 assert_eq!((stats.requeries, stats.changes), (2, 1));
1207 }
1208
1209 /// Delays every reply by `delay` (virtual time — `tokio::time::sleep`, NOT a
1210 /// thread sleep, so the paused clock controls it). `ScriptedRunner` replies
1211 /// instantly, so this is the only way to exercise the `requery_timeout`
1212 /// wrapper — a scripted `Reply::timeout()` resolves immediately and would
1213 /// test the *error* path, not the deadline.
1214 struct Sleepy {
1215 delay: Duration,
1216 gitdir: PathBuf,
1217 head: &'static str,
1218 }
1219
1220 #[async_trait::async_trait]
1221 impl ProcessRunner for Sleepy {
1222 async fn output_string(
1223 &self,
1224 command: &processkit::Command,
1225 ) -> processkit::Result<processkit::ProcessResult<String>> {
1226 tokio::time::sleep(self.delay).await;
1227 scripted(&self.gitdir, self.head)
1228 .output_string(command)
1229 .await
1230 }
1231 }
1232
1233 // A re-query exceeding the configured deadline is killed and skipped as
1234 // transient; the loop survives (a later attempt runs and is also bounded).
1235 #[tokio::test(start_paused = true)]
1236 async fn requery_timeout_skips_as_transient() {
1237 let scratch = Scratch::new();
1238 let prev = baseline(&scratch.0, "aaa").await;
1239 let repo = Box::new(Repo::from_git(
1240 "/r",
1241 "/r",
1242 Git::with_runner(Sleepy {
1243 delay: Duration::from_secs(10),
1244 gitdir: scratch.0.clone(),
1245 head: "bbb",
1246 }),
1247 ));
1248 let config = LoopConfig {
1249 requery_timeout: Some(Duration::from_secs(5)),
1250 ..defaults()
1251 };
1252 let mut h = spawn_loop(repo, prev, config);
1253
1254 h.signal();
1255 settle().await; // loop registers the quiet timer
1256 tokio::time::advance(Duration::from_millis(300)).await; // debounce
1257 settle().await; // re-query starts; Sleepy + the deadline register timers
1258 tokio::time::advance(Duration::from_secs(6)).await; // past the deadline
1259 settle().await;
1260 let stats = h.stats.snapshot();
1261 assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
1262 assert_eq!(stats.last_error, Some(WatcherErrorKind::Timeout));
1263 assert!(h.out.try_recv().is_err());
1264
1265 // The loop is alive: a second attempt runs (and times out the same way).
1266 h.signal();
1267 settle().await;
1268 tokio::time::advance(Duration::from_millis(300)).await;
1269 settle().await;
1270 tokio::time::advance(Duration::from_secs(6)).await;
1271 settle().await;
1272 assert_eq!(h.stats.snapshot().requeries, 2);
1273 }
1274
1275 // R4: the startup baseline honors `requery_timeout` — a snapshot that wedges (a
1276 // `Sleepy` repo far past the deadline) errors with `TimedOut` instead of hanging
1277 // `build()` forever. Exercises `capture_baseline` directly (the `build()` path is
1278 // only reachable with a real notify watcher).
1279 #[tokio::test(start_paused = true)]
1280 async fn baseline_capture_honors_requery_timeout() {
1281 let scratch = Scratch::new();
1282 let repo = Repo::from_git(
1283 "/r",
1284 "/r",
1285 Git::with_runner(Sleepy {
1286 delay: Duration::from_secs(10),
1287 gitdir: scratch.0.clone(),
1288 head: "bbb",
1289 }),
1290 );
1291 let err = capture_baseline(&repo, Some(Duration::from_secs(5)))
1292 .await
1293 .expect_err("a wedged baseline must time out, not hang");
1294 assert!(
1295 matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::TimedOut),
1296 "expected an Io TimedOut, got {err:?}"
1297 );
1298 // A wedged baseline is retryable — `build()` agrees with the loop's transient
1299 // treatment of a re-query timeout.
1300 assert!(err.is_transient(), "a baseline timeout is transient");
1301
1302 // With no deadline the same query completes (Sleepy still returns, just late);
1303 // advancing the clock lets it finish so we prove the timeout — not the repo —
1304 // is what produced the error above.
1305 let ok = capture_baseline(&repo, None).await;
1306 assert!(ok.is_ok(), "an unbounded baseline still succeeds: {ok:?}");
1307 }
1308
1309 // Closing the signal channel mid-debounce ends the loop promptly and closes
1310 // the output channel.
1311 #[tokio::test(start_paused = true)]
1312 async fn drop_teardown_mid_debounce() {
1313 let scratch = Scratch::new();
1314 let prev = baseline(&scratch.0, "aaa").await;
1315 let Harness {
1316 sig,
1317 mut out,
1318 stats: _,
1319 task,
1320 } = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1321
1322 sig.try_send(()).expect("send"); // empty capacity-1 channel → succeeds
1323 tokio::time::advance(Duration::from_millis(100)).await; // mid-debounce
1324 drop(sig);
1325
1326 tokio::time::timeout(Duration::from_secs(1), task)
1327 .await
1328 .expect("loop ends promptly")
1329 .expect("loop task joins cleanly");
1330 assert!(out.recv().await.is_none(), "output closes with the loop");
1331 }
1332
1333 /// Reports a different head on every `status` call, so every re-query
1334 /// produces a `HeadMoved` — the emission generator the backpressure test
1335 /// needs to fill the bounded output channel.
1336 struct VaryingHead {
1337 statuses: AtomicU64,
1338 gitdir: PathBuf,
1339 }
1340
1341 #[async_trait::async_trait]
1342 impl ProcessRunner for VaryingHead {
1343 async fn output_string(
1344 &self,
1345 command: &processkit::Command,
1346 ) -> processkit::Result<processkit::ProcessResult<String>> {
1347 let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1348 let n = if is_status {
1349 self.statuses.fetch_add(1, Ordering::Relaxed)
1350 } else {
1351 self.statuses.load(Ordering::Relaxed)
1352 };
1353 scripted(&self.gitdir, &format!("h{n}"))
1354 .output_string(command)
1355 .await
1356 }
1357 }
1358
1359 // A full output channel parks the loop at `send` (backpressure) instead of
1360 // dropping or buffering unboundedly; draining one item unparks it.
1361 #[tokio::test(start_paused = true)]
1362 async fn backpressure_parks_loop() {
1363 let scratch = Scratch::new();
1364 let prev = baseline(&scratch.0, "base").await;
1365 let repo = Box::new(Repo::from_git(
1366 "/r",
1367 "/r",
1368 Git::with_runner(VaryingHead {
1369 statuses: AtomicU64::new(0),
1370 gitdir: scratch.0.clone(),
1371 }),
1372 ));
1373 let config = LoopConfig {
1374 output_capacity: 1,
1375 ..defaults()
1376 };
1377 let mut h = spawn_loop(repo, prev, config);
1378
1379 // First change fills the capacity-1 channel.
1380 h.signal();
1381 settle().await; // loop registers the quiet timer
1382 tokio::time::advance(Duration::from_millis(300)).await;
1383 settle().await; // re-query runs; emission 1 fills the channel
1384 // Second re-query produces another change; the send parks (channel full):
1385 // the re-query ran but the emission hasn't landed.
1386 h.signal();
1387 settle().await;
1388 tokio::time::advance(Duration::from_millis(300)).await;
1389 settle().await;
1390 let stats = h.stats.snapshot();
1391 assert_eq!(
1392 (stats.requeries, stats.changes),
1393 (2, 1),
1394 "second emission must be parked on the full channel"
1395 );
1396
1397 // Draining unparks the loop; both changes arrive in order.
1398 let first = h.out.recv().await.expect("first change");
1399 assert!(
1400 first
1401 .events
1402 .iter()
1403 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1404 );
1405 let second = h.out.recv().await.expect("second change");
1406 assert!(
1407 second
1408 .events
1409 .iter()
1410 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1411 );
1412 settle().await;
1413 assert_eq!(h.stats.snapshot().changes, 2);
1414 }
1415
1416 // The `stream` feature: `StreamExt::next` on the REAL `RepoWatcher` yields
1417 // what `recv` would and advances `current()` identically. The watcher is
1418 // assembled directly (same crate) around the loop harness's channel, with an
1419 // idle notify watcher standing in for the OS watch.
1420 #[cfg(feature = "stream")]
1421 #[tokio::test(start_paused = true)]
1422 async fn stream_yields_changes_and_advances_current() {
1423 use tokio_stream::StreamExt;
1424
1425 let scratch = Scratch::new();
1426 let prev = baseline(&scratch.0, "aaa").await;
1427 let h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1428
1429 let baseline_snap = scripted_repo(&scratch.0, "aaa")
1430 .snapshot()
1431 .await
1432 .expect("baseline snapshot");
1433 let mut watcher = RepoWatcher {
1434 rx: h.out,
1435 current: baseline_snap,
1436 stats: h.stats,
1437 _watcher: notify::recommended_watcher(|_res| {}).expect("idle watcher"),
1438 task: h.task,
1439 };
1440 assert_eq!(watcher.current().head.as_deref(), Some("aaa"));
1441
1442 // `h` is partially moved into `watcher` above, so reach the remaining `sig`
1443 // field directly rather than through the `h.signal()` method (which would
1444 // borrow all of `h`).
1445 let _ = h.sig.try_send(());
1446 let change = watcher.next().await.expect("stream item");
1447 assert!(
1448 change
1449 .events
1450 .iter()
1451 .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1452 "got {:?}",
1453 change.events
1454 );
1455 // Polling through the Stream advanced `current()` exactly like `recv`.
1456 assert_eq!(watcher.current().head.as_deref(), Some("bbb"));
1457 }
1458}
1459
1460// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
1461#[doc = include_str!("../docs/watch.md")]
1462#[allow(rustdoc::broken_intra_doc_links)]
1463pub mod guide {}