Skip to main content

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 {}