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 the watch backend dies, is dropped, or
45//!   otherwise ends), or, under the **`stream`** feature, poll the watcher as a
46//!   `futures_core::Stream`. Both pull from the same channel and advance
47//!   [`current`](RepoWatcher::current), the last-pulled snapshot. A timed-out or
48//!   transiently failed re-query is **retried automatically** with bounded
49//!   exponential backoff — even with no new filesystem event — so a miss on the
50//!   last signal isn't stuck until the next one; a *permanent* OS-watch backend
51//!   failure (e.g. the watched `.git`/`.jj` dir was removed) closes this channel,
52//!   so `recv`/the stream observe it directly instead of requiring separate
53//!   stats polling.
54//! - **[`WatcherStats`]** ([`stats`](RepoWatcher::stats)) — lock-free health
55//!   counters (re-queries run, changes emitted, skips, retries, recoveries,
56//!   terminal failures, and the last skip's [`WatcherErrorKind`]). Climbing
57//!   [`skipped`](WatcherStats::skipped) with flat [`changes`](WatcherStats::changes)
58//!   means a wedged repo — poll it from a health check rather than inferring
59//!   health from event silence.
60//! - **[`Error`]** — a setup/build failure: a [`Vcs`](Error::Vcs) baseline
61//!   re-query error, an [`Io`](Error::Io) filesystem error, or a
62//!   [`Notify`](Error::Notify) filesystem-watch backend failure. The watch
63//!   backend is a **private** dependency, so its failures are the opaque
64//!   [`WatchError`] — classify them (`is_path_not_found` / `is_watch_limit` /
65//!   `io_error`) and source-chain them through `vcs-watch` alone, with no direct
66//!   dependency on the third-party watch crate to keep version-matched.
67//!
68//! # Recipes
69//!
70//! Watch with the defaults and react to each settled change:
71//!
72//! ```no_run
73//! use vcs_core::Repo;
74//! use vcs_watch::RepoWatcher;
75//! # async fn run() -> vcs_watch::Result<()> {
76//! let repo = Repo::discover(".")?;
77//! let mut watcher = RepoWatcher::watch(repo).await?;
78//! while let Some(change) = watcher.recv().await {
79//!     for event in &change.events {
80//!         println!("{event:?}");
81//!     }
82//!     // `change.snapshot` is the fresh full state — render a status line off it.
83//! }
84//! # Ok(()) }
85//! ```
86//!
87//! Under the **`stream`** feature the watcher *is* a `futures_core::Stream`,
88//! so it drops into stream combinators and `tokio::select!` directly (needs
89//! `futures`/`tokio-stream`'s `StreamExt` in scope):
90//!
91//! ```ignore
92//! use futures::StreamExt;
93//! use vcs_core::Repo;
94//! use vcs_watch::RepoWatcher;
95//! # async fn run() -> vcs_watch::Result<()> {
96//! let repo = Repo::discover(".")?;
97//! let mut watcher = RepoWatcher::watch(repo).await?;
98//! while let Some(change) = watcher.next().await {
99//!     println!("{} event(s)", change.events.len());
100//! }
101//! # Ok(()) }
102//! ```
103//!
104//! **Runtime:** unlike the rest of the toolkit (which hides tokio behind
105//! `processkit`), `vcs-watch` uses **tokio at runtime** — the watch task and the
106//! debounce timer run on the caller's tokio runtime, so build/await it from
107//! within one.
108//!
109//! # Testing
110//!
111//! The debounce → ceiling → re-query pipeline is a free function over injected
112//! seams, so it is exercised hermetically on a **paused clock** (no real
113//! filesystem or sleeps); a consumer's own watch code tests the same way it tests
114//! any [`vcs-core`](vcs_core) consumer — build the [`Repo`](vcs_core::Repo) over a
115//! fake runner (processkit's `ScriptedRunner`) so the re-query returns canned
116//! state. See
117//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
118//!
119//! # In-depth guide
120//!
121//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
122//! from `docs/`. See the [`guide`] module.
123
124use std::path::{Path, PathBuf};
125use std::sync::Arc;
126use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
127use std::time::Duration;
128
129use notify::{RecursiveMode, Watcher};
130use tokio::sync::mpsc;
131use vcs_core::{BackendKind, VcsRepo};
132
133mod error;
134mod event;
135
136pub use error::{Error, Result, WatchError};
137pub use event::{RepoChange, RepoEvent};
138// Re-export the snapshot types a consumer reads off a `RepoChange`, so depending
139// on `vcs-watch` alone suffices.
140pub use vcs_core::{OperationState, RepoSnapshot};
141// Re-export `processkit` so a `vcs-watch`-only consumer can name the
142// `Error::processkit_error()` return type without a direct `processkit`
143// dependency (mirrors `vcs_core::processkit` / `vcs_forge::processkit`).
144pub use processkit;
145
146/// Default quiet window: a re-query fires once the watched dir has been silent
147/// for this long after the last event.
148const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(250);
149/// Default ceiling: even under a continuous stream of events, re-query at least
150/// this often (so a long bulk operation still reports progress).
151const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(1);
152/// Upper clamp for [`max_wait`](Builder::max_wait) when it is turned into an
153/// `Instant` deadline. `Instant + Duration` *panics* on overflow, and `max_wait`
154/// is caller-settable with no bound (`.max_wait(Duration::MAX)` is a natural
155/// "disable the ceiling" idiom), so cap the addend at an effectively-unbounded
156/// one year — a huge value then disables the ceiling instead of panicking the
157/// spawned watch loop, which would drop the output channel and kill the watcher
158/// silently.
159const MAX_WAIT_CEILING: Duration = Duration::from_secs(60 * 60 * 24 * 365);
160/// Default deadline on a single re-query (`snapshot` + branch list): a wedged
161/// command (e.g. a held `index.lock` with no client timeout configured) is
162/// killed and skipped instead of stalling the watch loop forever.
163pub const DEFAULT_REQUERY_TIMEOUT: Duration = Duration::from_secs(30);
164/// Bounded output channel: a slow consumer applies backpressure (the loop pauses
165/// re-querying), and pending filesystem signals coalesce into one catch-up query.
166const OUTPUT_CAPACITY: usize = 64;
167const REQUERY_RETRY_LIMIT: u32 = 3;
168const REQUERY_RETRY_BACKOFF: Duration = Duration::from_millis(100);
169const REQUERY_RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5);
170
171#[derive(Clone, Copy)]
172enum WatchSignal {
173    Change,
174    BackendFailed,
175}
176
177/// The timing/capacity knobs the background loop runs under — bundled so the
178/// loop signature stays small and the hermetic tests can vary them (notably
179/// `output_capacity`, which the backpressure test shrinks to 1).
180struct LoopConfig {
181    debounce: Duration,
182    max_wait: Duration,
183    /// `None` disables the per-re-query deadline.
184    requery_timeout: Option<Duration>,
185    /// Whether a re-query may snapshot the jj working copy (opt-in mutation) or
186    /// must stay read-only (the default). See [`Builder::snapshot_working_copy`].
187    snapshot_working_copy: bool,
188    output_capacity: usize,
189    retry_limit: u32,
190    retry_backoff: Duration,
191}
192
193/// Builder for a [`RepoWatcher`] — set the watch scope and debounce timing, then
194/// [`build`](Builder::build).
195pub struct Builder {
196    repo: Box<dyn VcsRepo>,
197    working_tree: bool,
198    snapshot_working_copy: bool,
199    debounce: Duration,
200    max_wait: Duration,
201    requery_timeout: Option<Duration>,
202}
203
204impl Builder {
205    /// Also watch the **working tree** recursively, so a bare unstaged edit
206    /// (`vim file`) fires [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged)
207    /// immediately. Off by default (only the `.git`/`.jj` state dir is watched,
208    /// which catches an unstaged edit once it touches the index / a jj snapshot).
209    ///
210    /// Note: `notify` is `.gitignore`-unaware, so this also watches ignored and
211    /// build directories — heavier on a large tree.
212    ///
213    /// **jj note:** on jj, a bare working-tree edit only becomes an observable
214    /// state change once *something* snapshots the working copy. The re-query is
215    /// **read-only by default** (it must not itself snapshot — see
216    /// [`snapshot_working_copy`](Self::snapshot_working_copy)), so watching the
217    /// tree alone will not surface an unsnapshotted edit as a
218    /// [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged): the event fires once
219    /// a jj command (or another watcher opted into
220    /// [`snapshot_working_copy`](Self::snapshot_working_copy)) records it. Opt into
221    /// [`snapshot_working_copy(true)`](Self::snapshot_working_copy) to have the
222    /// re-query itself snapshot, at the cost of the watcher recording jj
223    /// operations.
224    pub fn working_tree(mut self, yes: bool) -> Self {
225        self.working_tree = yes;
226        self
227    }
228
229    /// Whether each re-query may let **jj snapshot the working copy** — off by
230    /// default, which keeps the watcher a pure *observer*.
231    ///
232    /// By default (`false`) the re-query is **read-only**: on jj it passes
233    /// `--ignore-working-copy` (via
234    /// [`Repo::snapshot_readonly`](vcs_core::Repo::snapshot_readonly)), so
235    /// observing the repo records **no** jj operation and never moves `@`. This is
236    /// almost always what you want: an ordinary jj query snapshots the working
237    /// copy as a side effect (taking the working-copy lock, recording an
238    /// operation, possibly moving `@`), so a naive watcher would *mutate* the very
239    /// state it reports — and, worse, a [`requery_timeout`](Self::requery_timeout)
240    /// firing mid-snapshot would abort that mutation.
241    ///
242    /// The trade-off (jj only): a bare working-tree edit that no jj command has
243    /// snapshotted yet is **not** reflected until a real jj operation records it.
244    /// If your consumer genuinely needs to observe such unsnapshotted edits (e.g.
245    /// a live "dirty" indicator driven purely by filesystem edits), set this
246    /// `true`: each re-query then snapshots the working copy (via
247    /// [`Repo::snapshot`](vcs_core::Repo::snapshot)), **recording a jj operation
248    /// and possibly moving `@`** — an explicit, opt-in mutation, not a hidden side
249    /// effect of reading. Pair it with [`working_tree(true)`](Self::working_tree)
250    /// so the tree edits actually trigger a re-query.
251    ///
252    /// On **git** this knob has no effect — git's status/branch queries never
253    /// record operations or move refs, so both modes behave identically.
254    pub fn snapshot_working_copy(mut self, yes: bool) -> Self {
255        self.snapshot_working_copy = yes;
256        self
257    }
258
259    /// The quiet window: re-query once the watched dir has been silent this long
260    /// after the last event (default 250 ms). Coalesces an operation's write
261    /// burst into one re-check.
262    pub fn debounce(mut self, window: Duration) -> Self {
263        self.debounce = window;
264        self
265    }
266
267    /// The ceiling on how long a continuous event stream defers the re-query
268    /// (default 1 s) — a long bulk operation still reports at this cadence.
269    pub fn max_wait(mut self, ceiling: Duration) -> Self {
270        self.max_wait = ceiling;
271        self
272    }
273
274    /// Deadline on a single re-query (the `snapshot` + branch-list pair), default
275    /// [`DEFAULT_REQUERY_TIMEOUT`] (30 s); `None` disables it. Orthogonal to
276    /// [`max_wait`](Self::max_wait): that bounds how long signals may *defer* a
277    /// re-query, this bounds how long one re-query may *run*. On overrun the
278    /// spawned commands are killed (kill-on-drop) and the re-query is retried
279    /// three times with bounded exponential backoff, even if no new filesystem
280    /// event arrives.
281    ///
282    /// It **also bounds the startup baseline** captured by [`build`](Self::build): a
283    /// baseline that overruns fails `build()` with a transient `Io` `TimedOut`
284    /// (`Error::is_transient()`), rather than hanging the caller — so a wedged repo
285    /// can't stall `build()` any more than it can stall the loop.
286    ///
287    /// Note: on a very large repository a *cold-cache* `git status` (first run
288    /// after a `gc`, or on a slow disk) can legitimately exceed the 30 s default
289    /// — raise it (or pass `None`) there; a watcher whose every re-query is
290    /// being killed shows up as climbing [`WatcherStats::skipped`] with flat
291    /// `changes`.
292    pub fn requery_timeout(mut self, timeout: Option<Duration>) -> Self {
293        self.requery_timeout = timeout;
294        self
295    }
296
297    /// Start watching. Captures the baseline state, registers the filesystem
298    /// watch, and spawns the background re-query task on the current tokio
299    /// runtime.
300    ///
301    /// The baseline capture is bounded by [`requery_timeout`](Self::requery_timeout),
302    /// so on a wedged repo `build()` returns a transient `Io` `TimedOut`
303    /// (`Error::is_transient()`) instead of hanging at startup — retry, or raise the
304    /// timeout.
305    pub async fn build(self) -> Result<RepoWatcher> {
306        let root = self.repo.root().to_path_buf();
307        // The dirs whose writes mean "re-check": the `.git`/`.jj` state dir, plus
308        // — for a linked git worktree — the *shared* git dir it points at via
309        // `commondir` (where `refs/heads/*` and `packed-refs` actually live, so
310        // branch create/delete is seen). See `state_dirs`.
311        let state_dirs = state_dirs(self.repo.kind(), &root)?;
312
313        // Bridge: notify's callback thread pushes a unit "something changed" signal
314        // per event; the debounce loop drains it. The channel is **capacity 1** and
315        // the callback uses `try_send`, so a burst *coalesces* into a single pending
316        // signal (extra events while one is pending are dropped — the loop re-queries
317        // the full snapshot anyway, so no state is lost). This bounds memory: an
318        // unbounded channel would grow without limit if the consumer stopped draining
319        // the output while a filesystem storm churned (R2). Build the watcher and
320        // register paths *before* the baseline snapshot, so a change racing the
321        // baseline is queued, not lost.
322        let (raw_tx, raw_rx) = mpsc::channel::<WatchSignal>(1);
323        let stats = Arc::new(StatsInner::default());
324        let cb_stats = Arc::clone(&stats);
325        // Sticky because the capacity-1 coalescing channel may already contain a
326        // change when notify reports its terminal backend error.
327        let watch_failed = Arc::new(AtomicBool::new(false));
328        let cb_watch_failed = Arc::clone(&watch_failed);
329        let mut watcher =
330            notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
331                // A backend error is sticky and terminal: the loop closes its
332                // public output channel so recv/Stream observes the failure
333                // without requiring stats polling. Ordinary events just mean
334                // "re-check"; their content is irrelevant.
335                if res.is_err() {
336                    cb_stats.note_watch_error();
337                    if !cb_watch_failed.swap(true, Ordering::AcqRel) {
338                        cb_stats.note_terminal_failure();
339                    }
340                }
341                // `try_send` on the capacity-1 channel: succeeds when no signal is
342                // pending, drops (coalesces) when one already is. Never blocks the
343                // notify callback thread; `Err` (full or loop-ended) is intentionally
344                // ignored.
345                let signal = if res.is_err() {
346                    WatchSignal::BackendFailed
347                } else {
348                    WatchSignal::Change
349                };
350                let _ = raw_tx.try_send(signal);
351            })?;
352        if self.working_tree {
353            watcher.watch(&root, RecursiveMode::Recursive)?;
354            // A worktree gitlink puts the real (private and shared) git dirs
355            // outside `root`; cover any not already under the recursive root watch.
356            for dir in &state_dirs {
357                if !dir.starts_with(&root) {
358                    watcher.watch(dir, RecursiveMode::Recursive)?;
359                }
360            }
361        } else {
362            for dir in &state_dirs {
363                watcher.watch(dir, RecursiveMode::Recursive)?;
364            }
365        }
366
367        // Capture the baseline under the same `requery_timeout` deadline the loop
368        // applies to every re-query (R4) — otherwise a snapshot that wedges (a hung
369        // fsmonitor, a network filesystem, a held jj lock) on a `Repo` built without
370        // its own `default_timeout` would hang `build()` at startup, the very failure
371        // the loop-side deadline exists to prevent.
372        let (snapshot, branches) = capture_baseline(
373            &*self.repo,
374            self.requery_timeout,
375            self.snapshot_working_copy,
376        )
377        .await?;
378        let baseline = snapshot.clone();
379        let prev = event::WatchState::from_snapshot(&snapshot, branches);
380
381        let config = LoopConfig {
382            debounce: self.debounce,
383            max_wait: self.max_wait,
384            requery_timeout: self.requery_timeout,
385            snapshot_working_copy: self.snapshot_working_copy,
386            output_capacity: OUTPUT_CAPACITY,
387            retry_limit: REQUERY_RETRY_LIMIT,
388            retry_backoff: REQUERY_RETRY_BACKOFF,
389        };
390        let (out_tx, out_rx) = mpsc::channel::<RepoChange>(config.output_capacity);
391        let task = tokio::spawn(watch_loop(
392            self.repo,
393            raw_rx,
394            out_tx,
395            prev,
396            config,
397            Arc::clone(&stats),
398            watch_failed,
399        ));
400
401        Ok(RepoWatcher {
402            rx: out_rx,
403            current: baseline,
404            stats,
405            _watcher: watcher,
406            task,
407        })
408    }
409}
410
411// --- Watcher health counters --------------------------------------------------
412
413/// What the last skipped re-query failed on (see [`WatcherStats::last_error`]).
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415#[non_exhaustive]
416pub enum WatcherErrorKind {
417    /// The snapshot re-query returned an error (e.g. a transiently held lock).
418    Snapshot,
419    /// The branch-list re-query returned an error.
420    Branches,
421    /// The re-query exceeded [`Builder::requery_timeout`] and was killed.
422    Timeout,
423}
424
425/// A cheap point-in-time copy of the watcher's health counters — see
426/// [`RepoWatcher::stats`]. Lets a long-running consumer notice a watcher that is
427/// silently skipping re-queries (e.g. a permanently wedged repository) instead
428/// of inferring health from event silence.
429#[derive(Debug, Clone, Copy)]
430#[non_exhaustive]
431pub struct WatcherStats {
432    /// Re-query attempts started, including automatic retry attempts.
433    pub requeries: u64,
434    /// Re-queries that emitted a [`RepoChange`] (the rest found no difference).
435    pub changes: u64,
436    /// Re-queries skipped — transient query failures plus deadline overruns.
437    pub skipped: u64,
438    /// Automatic re-query retries scheduled after transient failures/timeouts.
439    pub retries: u64,
440    /// Failed re-query sequences that later succeeded during automatic retry.
441    pub recoveries: u64,
442    /// Terminal filesystem-watch backend failures. When this increments, the
443    /// output channel is closed and [`RepoWatcher::recv`] returns `None`.
444    pub terminal_failures: u64,
445    /// What the most recent skip failed on; `None` when nothing was ever skipped.
446    pub last_error: Option<WatcherErrorKind>,
447    /// Filesystem-watch **errors** reported by the OS backend (via `notify`). A
448    /// non-zero — especially *climbing* — count means the underlying watch is
449    /// failing: most often the watched `.git`/`.jj` directory was **removed and
450    /// re-created** (a re-clone / `jj git init`), which invalidates the OS watch on
451    /// the old directory. Such a reported error terminates the watch: `recv`
452    /// returns `None` (and the stream ends), so the consumer can rebuild it.
453    ///
454    /// **Best-effort, platform-dependent.** It is reliable on **Windows**, where
455    /// removing the watched directory fails `ReadDirectoryChangesW` and `notify`
456    /// reports an error. On **Linux** (`inotify`) a removed/re-created directory may
457    /// surface as an ordinary event or a silent watch teardown rather than an error,
458    /// so `watch_errors` can stay `0` even as the watcher goes deaf — don't rely on
459    /// it as the sole liveness signal there.
460    pub watch_errors: u64,
461}
462
463/// Lock-free counter cell shared between the loop and `stats()` readers. Relaxed
464/// ordering is enough: the counters are independent monotonic telemetry, not a
465/// synchronization protocol.
466#[derive(Default)]
467struct StatsInner {
468    requeries: AtomicU64,
469    changes: AtomicU64,
470    skipped: AtomicU64,
471    /// 0 = none, else `WatcherErrorKind as u8 + 1`.
472    last_error: AtomicU8,
473    watch_errors: AtomicU64,
474    retries: AtomicU64,
475    recoveries: AtomicU64,
476    terminal_failures: AtomicU64,
477}
478
479impl StatsInner {
480    fn note_requery(&self) {
481        self.requeries.fetch_add(1, Ordering::Relaxed);
482    }
483
484    fn note_change(&self) {
485        self.changes.fetch_add(1, Ordering::Relaxed);
486    }
487
488    fn note_watch_error(&self) {
489        self.watch_errors.fetch_add(1, Ordering::Relaxed);
490    }
491
492    fn note_retry(&self) {
493        self.retries.fetch_add(1, Ordering::Relaxed);
494    }
495
496    fn note_recovery(&self) {
497        self.recoveries.fetch_add(1, Ordering::Relaxed);
498    }
499
500    fn note_terminal_failure(&self) {
501        self.terminal_failures.fetch_add(1, Ordering::Relaxed);
502    }
503
504    fn note_skip(&self, kind: WatcherErrorKind) {
505        self.skipped.fetch_add(1, Ordering::Relaxed);
506        let code = match kind {
507            WatcherErrorKind::Snapshot => 1,
508            WatcherErrorKind::Branches => 2,
509            WatcherErrorKind::Timeout => 3,
510        };
511        self.last_error.store(code, Ordering::Relaxed);
512    }
513
514    fn snapshot(&self) -> WatcherStats {
515        let last_error = match self.last_error.load(Ordering::Relaxed) {
516            1 => Some(WatcherErrorKind::Snapshot),
517            2 => Some(WatcherErrorKind::Branches),
518            3 => Some(WatcherErrorKind::Timeout),
519            _ => None,
520        };
521        WatcherStats {
522            requeries: self.requeries.load(Ordering::Relaxed),
523            changes: self.changes.load(Ordering::Relaxed),
524            skipped: self.skipped.load(Ordering::Relaxed),
525            retries: self.retries.load(Ordering::Relaxed),
526            recoveries: self.recoveries.load(Ordering::Relaxed),
527            terminal_failures: self.terminal_failures.load(Ordering::Relaxed),
528            last_error,
529            watch_errors: self.watch_errors.load(Ordering::Relaxed),
530        }
531    }
532}
533
534/// A live watch over a repository, yielding [`RepoChange`]s as the repo's state
535/// changes. Dropping it stops the filesystem watch and the background task.
536pub struct RepoWatcher {
537    rx: mpsc::Receiver<RepoChange>,
538    current: RepoSnapshot,
539    stats: Arc<StatsInner>,
540    // Held to keep the OS watch alive; dropping it ends the watch (and the loop).
541    _watcher: notify::RecommendedWatcher,
542    task: tokio::task::JoinHandle<()>,
543}
544
545impl RepoWatcher {
546    /// A builder over `repo` (any [`VcsRepo`] — e.g. a [`vcs_core::Repo`]).
547    pub fn builder(repo: impl VcsRepo + 'static) -> Builder {
548        Builder {
549            repo: Box::new(repo),
550            working_tree: false,
551            // Read-only re-query by default: an observer must not snapshot the jj
552            // working copy (record an operation / move `@`) merely by looking.
553            snapshot_working_copy: false,
554            debounce: DEFAULT_DEBOUNCE,
555            max_wait: DEFAULT_MAX_WAIT,
556            requery_timeout: Some(DEFAULT_REQUERY_TIMEOUT),
557        }
558    }
559
560    /// Start watching `repo` with the defaults (state dir only, 250 ms debounce).
561    pub async fn watch(repo: impl VcsRepo + 'static) -> Result<RepoWatcher> {
562        Self::builder(repo).build().await
563    }
564
565    /// Await the next settled change. Returns `None` when the filesystem backend
566    /// reports a terminal error, the watcher is dropped, or its task otherwise
567    /// ends. A backend error also increments
568    /// [`WatcherStats::terminal_failures`].
569    pub async fn recv(&mut self) -> Option<RepoChange> {
570        let change = self.rx.recv().await?;
571        self.current = change.snapshot.clone();
572        Some(change)
573    }
574
575    /// The most recent known snapshot — the baseline captured at
576    /// [`build`](Builder::build), then the snapshot from each [`recv`](Self::recv).
577    /// It advances **only when you call [`recv`](Self::recv)**, so it is as fresh
578    /// as your last `recv`, not a live view.
579    pub fn current(&self) -> &RepoSnapshot {
580        &self.current
581    }
582
583    /// The watcher's health counters (re-queries run / changes emitted / skips,
584    /// retry/recovery/terminal outcomes, the last skip, and OS-watch errors).
585    /// Cheap relaxed-atomic
586    /// reads — poll it from a health check or log it periodically; a climbing
587    /// [`skipped`](WatcherStats::skipped) with flat
588    /// [`changes`](WatcherStats::changes) means the repository is wedged, and a
589    /// non-zero [`terminal_failures`](WatcherStats::terminal_failures) means the
590    /// output channel has terminated after an OS-watch backend error.
591    pub fn stats(&self) -> WatcherStats {
592        self.stats.snapshot()
593    }
594}
595
596/// Yields each settled [`RepoChange`] as a stream item (the `stream` feature).
597/// Equivalent to looping [`recv`](RepoWatcher::recv) — both pull from the same
598/// underlying channel (an item is delivered to whichever is polled first, never
599/// duplicated) and both advance [`current`](RepoWatcher::current).
600#[cfg(feature = "stream")]
601#[cfg_attr(docsrs, doc(cfg(feature = "stream")))]
602impl futures_core::Stream for RepoWatcher {
603    type Item = RepoChange;
604
605    fn poll_next(
606        self: std::pin::Pin<&mut Self>,
607        cx: &mut std::task::Context<'_>,
608    ) -> std::task::Poll<Option<RepoChange>> {
609        // All fields are Unpin, so the watcher is Unpin and get_mut is sound.
610        let this = self.get_mut();
611        match this.rx.poll_recv(cx) {
612            std::task::Poll::Ready(Some(change)) => {
613                this.current = change.snapshot.clone();
614                std::task::Poll::Ready(Some(change))
615            }
616            other => other,
617        }
618    }
619}
620
621impl Drop for RepoWatcher {
622    fn drop(&mut self) {
623        // The dropped `_watcher` already closes the signal channel (ending the
624        // loop); abort is belt-and-braces for prompt teardown.
625        self.task.abort();
626    }
627}
628
629/// The batched state read a re-query (and the startup baseline) performs: the
630/// snapshot plus the local-branch set. Routed through the **read-only** facade
631/// methods by default (`snapshot_working_copy == false`) so an observer never
632/// snapshots the jj working copy — no operation recorded, `@` unmoved. When the
633/// consumer opts into [`Builder::snapshot_working_copy`], it uses the ordinary
634/// (working-copy-snapshotting) facade methods instead, an explicit mutation.
635///
636/// The two calls are sequenced (branches after the snapshot) so both reflect the
637/// same observation, matching the previous behaviour.
638async fn read_state(
639    repo: &dyn VcsRepo,
640    snapshot_working_copy: bool,
641) -> vcs_core::Result<(vcs_core::RepoSnapshot, Vec<String>)> {
642    if snapshot_working_copy {
643        let snapshot = repo.snapshot().await?;
644        let branches = repo.local_branches().await?;
645        Ok((snapshot, branches))
646    } else {
647        let snapshot = repo.snapshot_readonly().await?;
648        let branches = repo.local_branches_readonly().await?;
649        Ok((snapshot, branches))
650    }
651}
652
653/// Capture the startup baseline (snapshot + local branches) under `requery_timeout`
654/// (R4). A `Some(limit)` bounds the whole capture with `tokio::time::timeout`; on
655/// expiry it returns [`Error::Io`] `TimedOut` and dropping the future kills the
656/// underlying process (kill-on-drop), exactly as the loop does for a re-query — so a
657/// wedged snapshot can't hang `build()` forever. `None` leaves it unbounded.
658///
659/// `snapshot_working_copy` picks the read-only vs working-copy-snapshotting facade
660/// methods (see [`read_state`]), so the baseline is captured under the **same**
661/// observation contract the loop then uses for every re-query.
662async fn capture_baseline(
663    repo: &dyn VcsRepo,
664    requery_timeout: Option<Duration>,
665    snapshot_working_copy: bool,
666) -> Result<(vcs_core::RepoSnapshot, Vec<String>)> {
667    let query = async {
668        read_state(repo, snapshot_working_copy)
669            .await
670            .map_err(Error::from)
671    };
672    match requery_timeout {
673        Some(limit) => match tokio::time::timeout(limit, query).await {
674            Ok(result) => result,
675            Err(_elapsed) => Err(Error::Io(std::io::Error::new(
676                std::io::ErrorKind::TimedOut,
677                format!("baseline snapshot exceeded the {limit:?} requery_timeout"),
678            ))),
679        },
680        None => query.await,
681    }
682}
683
684/// The background loop: coalesce a burst of filesystem signals, re-query the
685/// settled state, diff against the previous, and emit a [`RepoChange`] when
686/// anything changed.
687///
688/// A free function over plain channels + a [`VcsRepo`] (not a method) on
689/// purpose: the hermetic pipeline tests below drive it directly — a fake signal
690/// channel in, a `ScriptedRunner`-backed `Repo`, a paused tokio clock — pinning
691/// the debounce/ceiling/skip semantics without any real filesystem or process.
692async fn watch_loop(
693    repo: Box<dyn VcsRepo>,
694    mut raw_rx: mpsc::Receiver<WatchSignal>,
695    out_tx: mpsc::Sender<RepoChange>,
696    mut prev: event::WatchState,
697    config: LoopConfig,
698    stats: Arc<StatsInner>,
699    watch_failed: Arc<AtomicBool>,
700) {
701    'watch: loop {
702        // Block until the first signal (or exit when the watcher is dropped).
703        match raw_rx.recv().await {
704            None | Some(WatchSignal::BackendFailed) => return,
705            Some(WatchSignal::Change) if watch_failed.load(Ordering::Acquire) => return,
706            Some(WatchSignal::Change) => {}
707        }
708        // Coalesce the burst: reset a `debounce` quiet-timer on every new signal,
709        // but never wait past `max_wait` total. The dedicated `sleep_until` arm
710        // makes the ceiling exact (it fires even when no further signal arrives);
711        // the in-arm deadline check guards against a signal stream so dense that
712        // the `biased` select never polls the timer arms.
713        if drain(&mut raw_rx) || watch_failed.load(Ordering::Acquire) {
714            return;
715        }
716        // Clamp the addend: `Instant + Duration` panics on overflow, and a huge
717        // caller `max_wait` (e.g. `Duration::MAX`) must disable the ceiling, not
718        // crash the loop. See [`MAX_WAIT_CEILING`].
719        let deadline = tokio::time::Instant::now() + config.max_wait.min(MAX_WAIT_CEILING);
720        loop {
721            tokio::select! {
722                biased;
723                sig = raw_rx.recv() => {
724                    match sig {
725                        None | Some(WatchSignal::BackendFailed) => return,
726                        Some(WatchSignal::Change) => {}
727                    }
728                    if watch_failed.load(Ordering::Acquire) {
729                        return;
730                    }
731                    // Collapse the queued backlog: under a notify storm each
732                    // queued unit signal would otherwise cost a select iteration
733                    // that re-creates BOTH timer futures — a burst is one
734                    // "still busy" observation, not N.
735                    if drain(&mut raw_rx) {
736                        return;
737                    }
738                    if tokio::time::Instant::now() >= deadline {
739                        break; // ceiling reached — re-query now
740                    }
741                    // else: another event — loop resets the quiet timer
742                }
743                _ = tokio::time::sleep_until(deadline) => break, // ceiling
744                _ = tokio::time::sleep(config.debounce) => break, // settled
745            }
746        }
747
748        // Re-query the settled state, bounded by the configured deadline — a
749        // wedged command (a held `index.lock` on a client with no timeout) must
750        // not stall the watch forever. Dropping the overrun future kills the
751        // spawned process tree (processkit's kill-on-drop group), so a timed-out
752        // query leaves no orphan. Failures and overruns are transient skips:
753        // counted, traced, and retried with bounded exponential backoff.
754        //
755        // Deadline safety (jj): the default re-query is **read-only**
756        // (`snapshot_working_copy == false` → `snapshot_readonly`/
757        // `local_branches_readonly`, i.e. jj `--ignore-working-copy`), so it takes
758        // no working-copy lock and records no operation — a `requery_timeout`
759        // kill-on-drop can only interrupt a pure read, never a working-copy
760        // snapshot mid-write. Only the explicit opt-in
761        // (`snapshot_working_copy == true`) runs a mutating snapshot here, and that
762        // is the caller's documented choice, not a read masquerading as read-only.
763        let mut retry = 0;
764        let (snapshot, branches) = loop {
765            stats.note_requery();
766            let requery = async {
767                let (snapshot, branches) = if config.snapshot_working_copy {
768                    let snapshot = repo
769                        .snapshot()
770                        .await
771                        .map_err(|e| (WatcherErrorKind::Snapshot, e))?;
772                    let branches = repo
773                        .local_branches()
774                        .await
775                        .map_err(|e| (WatcherErrorKind::Branches, e))?;
776                    (snapshot, branches)
777                } else {
778                    let snapshot = repo
779                        .snapshot_readonly()
780                        .await
781                        .map_err(|e| (WatcherErrorKind::Snapshot, e))?;
782                    let branches = repo
783                        .local_branches_readonly()
784                        .await
785                        .map_err(|e| (WatcherErrorKind::Branches, e))?;
786                    (snapshot, branches)
787                };
788                Ok::<_, (WatcherErrorKind, vcs_core::Error)>((snapshot, branches))
789            };
790            let outcome = match config.requery_timeout {
791                Some(limit) => match tokio::time::timeout(limit, requery).await {
792                    Ok(result) => result.map_err(Some),
793                    Err(_elapsed) => {
794                        stats.note_skip(WatcherErrorKind::Timeout);
795                        #[cfg(feature = "tracing")]
796                        tracing::debug!(
797                            timeout = ?limit,
798                            retry,
799                            "vcs-watch: re-query exceeded its deadline; scheduling retry"
800                        );
801                        Err(None)
802                    }
803                },
804                None => requery.await.map_err(Some),
805            };
806            let result = match outcome {
807                Ok(pair) => Some(pair),
808                Err(Some((kind, _e))) => {
809                    stats.note_skip(kind);
810                    #[cfg(feature = "tracing")]
811                    tracing::debug!(
812                        error = %_e,
813                        retry,
814                        "vcs-watch: re-query failed; scheduling retry"
815                    );
816                    None
817                }
818                Err(None) => None,
819            };
820            if let Some(pair) = result {
821                if retry > 0 {
822                    stats.note_recovery();
823                }
824                break pair;
825            }
826            if retry >= config.retry_limit {
827                // The bounded sequence is exhausted. A future filesystem event
828                // starts a fresh sequence, preserving long-term recovery without
829                // spinning forever on a permanently broken repository.
830                continue 'watch;
831            }
832            stats.note_retry();
833            let delay = retry_backoff(config.retry_backoff, retry);
834            retry += 1;
835            let deadline = tokio::time::Instant::now() + delay;
836            loop {
837                tokio::select! {
838                    signal = raw_rx.recv() => match signal {
839                        None | Some(WatchSignal::BackendFailed) => return,
840                        Some(WatchSignal::Change) => {
841                            if drain(&mut raw_rx) || watch_failed.load(Ordering::Acquire) {
842                                return;
843                            }
844                            // Coalesce new changes into the already-scheduled
845                            // catch-up query, but retain the backoff deadline.
846                        }
847                    },
848                    _ = tokio::time::sleep_until(deadline) => break,
849                }
850            }
851        };
852
853        if watch_failed.load(Ordering::Acquire) {
854            return;
855        }
856        let next = event::WatchState::from_snapshot(&snapshot, branches);
857        let events = event::diff(&prev, &next);
858        prev = next;
859        if events.is_empty() {
860            continue;
861        }
862        if out_tx.send(RepoChange { snapshot, events }).await.is_err() {
863            return; // receiver dropped — stop
864        }
865        stats.note_change();
866    }
867}
868
869/// Drop every already-queued unit signal — the burst is one observation. Leaves
870/// channel-closed detection to the caller's next `recv` (a drained-empty and a
871/// closed channel both just stop yielding here).
872fn drain(raw_rx: &mut mpsc::Receiver<WatchSignal>) -> bool {
873    let mut failed = false;
874    while let Ok(signal) = raw_rx.try_recv() {
875        failed |= matches!(signal, WatchSignal::BackendFailed);
876    }
877    failed
878}
879
880fn retry_backoff(base: Duration, retry: u32) -> Duration {
881    base.saturating_mul(1_u32.checked_shl(retry).unwrap_or(u32::MAX))
882        .min(REQUERY_RETRY_BACKOFF_MAX)
883}
884
885/// The directories to watch for a backend, deduplicated. Normally one — the
886/// `.git`/`.jj` state dir (see [`state_dir`]) — but a **linked git worktree** has
887/// two: its private gitdir (HEAD/index/logs) *and* the shared git dir it points
888/// at via `commondir` (`refs/heads/*` and `packed-refs`, where branch
889/// create/delete actually lands). Watching only the private dir would miss every
890/// `BranchCreated`/`BranchDeleted` on a worktree, since the shared dir is a
891/// *sibling*, not nested under it (see [`common_dir`]).
892///
893/// A colocated jj repository also watches its `.git` directory (or resolved
894/// gitlink). Git-only operations do not touch `.jj`; the `.git` event provides
895/// the re-query signal even though jj imports that data only when the next jj
896/// snapshot triggers auto-import.
897///
898/// Overlapping watches are harmless — the re-query+debounce coalesces duplicate
899/// signals — but we drop a second dir whose normalized path equals the first, so
900/// `notify` isn't asked to watch the same path twice.
901fn state_dirs(kind: BackendKind, root: &Path) -> Result<Vec<PathBuf>> {
902    let primary_state_dir = state_dir(kind, root)?;
903    let mut dirs = vec![primary_state_dir.clone()];
904
905    let mut add_git_dirs = |git_dir: PathBuf| {
906        if !dirs.iter().any(|dir| normalize(dir) == normalize(&git_dir)) {
907            dirs.push(git_dir.clone());
908        }
909        if let Some(shared) = common_dir(&git_dir)
910            && !dirs.iter().any(|dir| normalize(dir) == normalize(&shared))
911        {
912            dirs.push(shared);
913        }
914    };
915
916    match kind {
917        BackendKind::Git => add_git_dirs(primary_state_dir),
918        BackendKind::Jj if root.join(".git").exists() => {
919            add_git_dirs(state_dir(BackendKind::Git, root)?)
920        }
921        _ => {}
922    }
923    Ok(dirs)
924}
925
926/// The directory to watch for a backend: `.jj` for jj, `.git` for git. A
927/// worktree's `.git` is a gitlink *file* (`gitdir: <path>`); resolve it to the
928/// real git directory. Best-effort — falls back to the `.git` path itself.
929fn state_dir(kind: BackendKind, root: &Path) -> Result<PathBuf> {
930    match kind {
931        BackendKind::Jj => Ok(root.join(".jj")),
932        BackendKind::Git => {
933            let dot_git = root.join(".git");
934            if dot_git.is_file() {
935                let content = std::fs::read_to_string(&dot_git)?;
936                if let Some(rest) = content.trim().strip_prefix("gitdir:") {
937                    let p = PathBuf::from(rest.trim());
938                    return Ok(if p.is_absolute() { p } else { root.join(p) });
939                }
940            }
941            Ok(dot_git)
942        }
943        // `BackendKind` is `#[non_exhaustive]`; for an unknown future backend
944        // watch the repo root itself — coarser, but it can't miss the state dir.
945        _ => Ok(root.to_path_buf()),
946    }
947}
948
949/// The **shared** git directory for a linked worktree, or `None` for a plain
950/// repo. A linked worktree's resolved gitdir holds a `commondir` file whose
951/// content is a path (typically relative, e.g. `../..`) to the shared `.git` —
952/// where `refs/heads/*` and `packed-refs` live. We join it to the gitdir and
953/// resolve `..` (lexically, matching the no-canonicalize style of [`state_dir`],
954/// so the registered path stays plain rather than a Windows `\\?\` verbatim one).
955/// A plain repo has no `commondir` file, so this is `None` and behaviour is
956/// unchanged.
957fn common_dir(state_dir: &Path) -> Option<PathBuf> {
958    let commondir = state_dir.join("commondir");
959    let content = std::fs::read_to_string(&commondir).ok()?;
960    let rel = content.trim();
961    if rel.is_empty() {
962        return None;
963    }
964    let p = PathBuf::from(rel);
965    let joined = if p.is_absolute() {
966        p
967    } else {
968        state_dir.join(p)
969    };
970    Some(lexically_normalized(&joined))
971}
972
973/// Resolve `.`/`..` components without touching the filesystem, keeping the path
974/// in its original (non-verbatim) form — `commondir`'s `../..` plus a Windows
975/// gitdir would otherwise leave literal `..` segments in the watched path.
976fn lexically_normalized(p: &Path) -> PathBuf {
977    use std::path::Component;
978    let mut out = PathBuf::new();
979    for comp in p.components() {
980        match comp {
981            Component::ParentDir => {
982                // Pop a real segment; keep a leading `..` that can't be resolved.
983                if !out.pop() {
984                    out.push(comp);
985                }
986            }
987            Component::CurDir => {}
988            other => out.push(other),
989        }
990    }
991    out
992}
993
994/// Canonicalize for comparison and strip the Windows verbatim prefix (`\\?\…`,
995/// which `canonicalize` adds), so two spellings of the same dir dedup. Mirrors
996/// `vcs-core`'s path-compare normalization; falls back to the input when the path
997/// can't be canonicalized (then equal paths still compare equal byte-for-byte).
998fn normalize(p: &Path) -> PathBuf {
999    let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1000    #[cfg(windows)]
1001    {
1002        let s = canonical.to_string_lossy();
1003        if let Some(rest) = s.strip_prefix(r"\\?\")
1004            && !rest.starts_with("UNC\\")
1005        {
1006            return PathBuf::from(rest.to_string());
1007        }
1008    }
1009    canonical
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use std::sync::atomic::{AtomicU64, Ordering};
1016
1017    static COUNTER: AtomicU64 = AtomicU64::new(0);
1018
1019    /// A unique, self-cleaning temp dir (no temp-dir crate needed for these
1020    /// hermetic helper tests — pid + counter keeps parallel tests from colliding).
1021    /// `pub(crate)`: the pipeline tests below reuse it for the scripted repo's
1022    /// on-disk git dir (the snapshot's MERGE_HEAD probe reads the filesystem).
1023    pub(crate) struct Scratch(pub(crate) PathBuf);
1024    impl Scratch {
1025        pub(crate) fn new() -> Self {
1026            let p = std::env::temp_dir().join(format!(
1027                "vcs-watch-commondir-{}-{}",
1028                std::process::id(),
1029                COUNTER.fetch_add(1, Ordering::Relaxed)
1030            ));
1031            std::fs::create_dir_all(&p).expect("create scratch dir");
1032            Scratch(p)
1033        }
1034    }
1035    impl Drop for Scratch {
1036        fn drop(&mut self) {
1037            let _ = std::fs::remove_dir_all(&self.0);
1038        }
1039    }
1040
1041    // A plain (non-worktree) git dir has no `commondir` file → no shared dir, so
1042    // behaviour is exactly today's single-dir watch.
1043    #[test]
1044    fn no_commondir_file_yields_none() {
1045        let scratch = Scratch::new();
1046        let git_dir = scratch.0.join(".git");
1047        std::fs::create_dir_all(&git_dir).expect("mkdir .git");
1048        assert_eq!(common_dir(&git_dir), None);
1049    }
1050
1051    // A linked-worktree layout: the private gitdir holds `commondir` = `../..`
1052    // (git's actual content), which must resolve to the sibling shared `.git`.
1053    #[test]
1054    fn relative_commondir_resolves_to_shared_git_dir() {
1055        let scratch = Scratch::new();
1056        let shared = scratch.0.join(".git");
1057        let private = shared.join("worktrees").join("wt");
1058        std::fs::create_dir_all(&private).expect("mkdir private gitdir");
1059        // git writes `../..` (relative to the private dir) here.
1060        std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
1061
1062        let resolved = common_dir(&private).expect("Some(shared dir)");
1063        // `<shared>/worktrees/wt` + `../..` == `<shared>` (lexically, no `..` left).
1064        assert_eq!(resolved, lexically_normalized(&shared));
1065        assert!(
1066            !resolved.to_string_lossy().contains(".."),
1067            "the `..` segments must be resolved, got {}",
1068            resolved.display()
1069        );
1070    }
1071
1072    // An absolute `commondir` (git permits it) is taken as-is.
1073    #[test]
1074    fn absolute_commondir_is_used_verbatim() {
1075        let scratch = Scratch::new();
1076        let shared = scratch.0.join("shared-git");
1077        let private = scratch.0.join("private");
1078        std::fs::create_dir_all(&private).expect("mkdir private");
1079        std::fs::write(private.join("commondir"), format!("{}\n", shared.display()))
1080            .expect("write commondir");
1081
1082        assert_eq!(common_dir(&private), Some(lexically_normalized(&shared)));
1083    }
1084
1085    // `state_dirs` returns both the private and shared dirs for a worktree, and
1086    // the shared dir is not the private one (so two distinct watches register).
1087    #[test]
1088    fn state_dirs_includes_private_and_shared_for_worktree() {
1089        let scratch = Scratch::new();
1090        let root = scratch.0.join("wt-worktree");
1091        let shared = scratch.0.join(".git");
1092        let private = shared.join("worktrees").join("wt");
1093        std::fs::create_dir_all(&private).expect("mkdir private gitdir");
1094        std::fs::create_dir_all(&root).expect("mkdir worktree root");
1095        std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
1096        // The worktree's `.git` gitlink file points at the private dir.
1097        std::fs::write(
1098            root.join(".git"),
1099            format!("gitdir: {}\n", private.display()),
1100        )
1101        .expect("write gitlink");
1102
1103        let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
1104        assert_eq!(dirs.len(), 2, "private + shared, got {dirs:?}");
1105        assert_eq!(normalize(&dirs[0]), normalize(&private));
1106        assert_eq!(normalize(&dirs[1]), normalize(&shared));
1107    }
1108
1109    #[test]
1110    fn state_dirs_includes_git_dir_for_colocated_jj_repo() {
1111        let scratch = Scratch::new();
1112        let root = scratch.0.join("colocated");
1113        std::fs::create_dir_all(root.join(".jj")).expect("mkdir .jj");
1114        std::fs::create_dir_all(root.join(".git")).expect("mkdir .git");
1115
1116        let dirs = state_dirs(BackendKind::Jj, &root).expect("state_dirs");
1117        assert_eq!(dirs, vec![root.join(".jj"), root.join(".git")]);
1118    }
1119
1120    #[test]
1121    fn state_dirs_excludes_missing_git_dir_for_pure_jj_repo() {
1122        let scratch = Scratch::new();
1123        let root = scratch.0.join("pure-jj");
1124        std::fs::create_dir_all(root.join(".jj")).expect("mkdir .jj");
1125
1126        let dirs = state_dirs(BackendKind::Jj, &root).expect("state_dirs");
1127        assert_eq!(dirs, vec![root.join(".jj")]);
1128    }
1129
1130    // When `commondir` resolves back to the state dir itself (degenerate), the
1131    // duplicate is dropped — we never register the same path twice.
1132    #[test]
1133    fn self_referential_commondir_is_deduped() {
1134        let scratch = Scratch::new();
1135        let git_dir = scratch.0.join(".git");
1136        std::fs::create_dir_all(&git_dir).expect("mkdir .git");
1137        // `.` resolves to the dir itself.
1138        std::fs::write(git_dir.join("commondir"), ".\n").expect("write commondir");
1139        // The gitlink points the worktree root at this very dir.
1140        let root = scratch.0.join("root");
1141        std::fs::create_dir_all(&root).expect("mkdir root");
1142        std::fs::write(
1143            root.join(".git"),
1144            format!("gitdir: {}\n", git_dir.display()),
1145        )
1146        .expect("write gitlink");
1147
1148        let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
1149        assert_eq!(dirs.len(), 1, "self-reference deduped, got {dirs:?}");
1150    }
1151
1152    // R3: verify the `watch_errors` counter→`snapshot()` plumbing (the notify
1153    // callback that calls `note_watch_error` on a backend `Err` can't be driven from
1154    // a unit test, so this pins the counter is wired in and stays independent).
1155    #[test]
1156    fn stats_counts_watch_errors_independently() {
1157        let stats = StatsInner::default();
1158        assert_eq!(stats.snapshot().watch_errors, 0);
1159        stats.note_watch_error();
1160        stats.note_watch_error();
1161        let snap = stats.snapshot();
1162        assert_eq!(snap.watch_errors, 2, "watch errors counted");
1163        assert_eq!(
1164            (snap.requeries, snap.changes, snap.skipped),
1165            (0, 0, 0),
1166            "other counters unaffected"
1167        );
1168        assert_eq!(
1169            (snap.retries, snap.recoveries, snap.terminal_failures),
1170            (0, 0, 0),
1171            "retry lifecycle counters unaffected"
1172        );
1173        assert!(snap.last_error.is_none());
1174    }
1175}
1176
1177/// Hermetic tests of the debounce → ceiling → re-query → diff pipeline itself:
1178/// `watch_loop` is driven directly with a fake signal channel, a
1179/// `ScriptedRunner`-backed `Repo`, and a **paused tokio clock** — no real
1180/// filesystem watch, no real process, no real sleeps. These pin the *loop's*
1181/// timing contract; the notify→signal bridge stays covered by the `#[ignore]`
1182/// integration tests (fake time says nothing about real OS event batching).
1183#[cfg(test)]
1184mod pipeline_tests {
1185    use super::tests::Scratch;
1186    use super::*;
1187    use processkit::ProcessRunner;
1188    use processkit::testing::{Reply, ScriptedRunner};
1189    use vcs_core::Repo;
1190    use vcs_core::vcs_git::Git;
1191
1192    /// Porcelain-v2 (NUL-separated) status output for a repo at `head`, clean.
1193    fn v2(head: &str) -> String {
1194        format!("# branch.oid {head}\0# branch.head main\0")
1195    }
1196
1197    /// The exact command set one snapshot+branches re-query issues, scripted:
1198    /// `status --porcelain=v2`, the `rev-parse --git-dir` probe (must point at a
1199    /// real dir — the op-state probe reads `MERGE_HEAD` off the filesystem), and
1200    /// `branch --no-column`.
1201    fn scripted(gitdir: &Path, head: &str) -> ScriptedRunner {
1202        ScriptedRunner::new()
1203            .on(["git", "status"], Reply::ok(v2(head)))
1204            .on(
1205                ["git", "rev-parse"],
1206                Reply::ok(format!("{}\n", gitdir.display())),
1207            )
1208            .on(["git", "branch"], Reply::ok("* main\n"))
1209    }
1210
1211    fn scripted_repo(gitdir: &Path, head: &str) -> Box<dyn VcsRepo> {
1212        Box::new(Repo::from_git(
1213            "/r",
1214            "/r",
1215            Git::with_runner(scripted(gitdir, head)),
1216        ))
1217    }
1218
1219    /// The baseline `prev` state the loop diffs against, taken through the same
1220    /// snapshot path `Builder::build` uses.
1221    async fn baseline(gitdir: &Path, head: &str) -> event::WatchState {
1222        let repo = scripted_repo(gitdir, head);
1223        let snap = repo.snapshot().await.expect("baseline snapshot");
1224        let branches = repo.local_branches().await.expect("baseline branches");
1225        event::WatchState::from_snapshot(&snap, branches)
1226    }
1227
1228    fn defaults() -> LoopConfig {
1229        LoopConfig {
1230            debounce: Duration::from_millis(250),
1231            max_wait: Duration::from_secs(1),
1232            requery_timeout: Some(Duration::from_secs(30)),
1233            // The hermetic pipeline drives a git-backed scripted repo, where
1234            // read-only and snapshotting re-queries issue the same commands; the
1235            // default (read-only) mirrors production.
1236            snapshot_working_copy: false,
1237            output_capacity: 64,
1238            retry_limit: REQUERY_RETRY_LIMIT,
1239            retry_backoff: REQUERY_RETRY_BACKOFF,
1240        }
1241    }
1242
1243    struct Harness {
1244        sig: mpsc::Sender<WatchSignal>,
1245        out: mpsc::Receiver<RepoChange>,
1246        stats: Arc<StatsInner>,
1247        watch_failed: Arc<AtomicBool>,
1248        task: tokio::task::JoinHandle<()>,
1249    }
1250
1251    impl Harness {
1252        // Mirror the production notify callback: fire-and-forget `try_send` on the
1253        // capacity-1 bridge (a pending signal coalesces the next one). `Err` (full or
1254        // loop-ended) is intentionally ignored — a still-pending signal already
1255        // triggers the re-query the caller wants.
1256        fn signal(&self) {
1257            let _ = self.sig.try_send(WatchSignal::Change);
1258        }
1259
1260        fn backend_failed(&self) {
1261            self.stats.note_watch_error();
1262            if !self.watch_failed.swap(true, Ordering::AcqRel) {
1263                self.stats.note_terminal_failure();
1264            }
1265            let _ = self.sig.try_send(WatchSignal::BackendFailed);
1266        }
1267    }
1268
1269    fn spawn_loop(repo: Box<dyn VcsRepo>, prev: event::WatchState, config: LoopConfig) -> Harness {
1270        let (sig, raw_rx) = mpsc::channel(1);
1271        let (out_tx, out) = mpsc::channel(config.output_capacity);
1272        let stats = Arc::new(StatsInner::default());
1273        let watch_failed = Arc::new(AtomicBool::new(false));
1274        let task = tokio::spawn(watch_loop(
1275            repo,
1276            raw_rx,
1277            out_tx,
1278            prev,
1279            config,
1280            Arc::clone(&stats),
1281            Arc::clone(&watch_failed),
1282        ));
1283        Harness {
1284            sig,
1285            out,
1286            stats,
1287            watch_failed,
1288            task,
1289        }
1290    }
1291
1292    /// Let the loop task run to a quiescent point without advancing time —
1293    /// paused-clock auto-advance only triggers when every task idles on a timer,
1294    /// so a bounded yield burst (never a spin-until loop) is the safe way to let
1295    /// an already-runnable re-query complete.
1296    async fn settle() {
1297        for _ in 0..32 {
1298            tokio::task::yield_now().await;
1299        }
1300    }
1301
1302    // A burst of sub-debounce signals coalesces into exactly one re-query and
1303    // one emitted change.
1304    #[tokio::test(start_paused = true)]
1305    async fn debounce_coalesces_burst() {
1306        let scratch = Scratch::new();
1307        let prev = baseline(&scratch.0, "aaa").await;
1308        let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1309
1310        for _ in 0..5 {
1311            h.signal();
1312            tokio::time::advance(Duration::from_millis(10)).await;
1313        }
1314        let change = h.out.recv().await.expect("one coalesced change");
1315        assert!(
1316            change
1317                .events
1318                .iter()
1319                .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1320            "expected HeadMoved, got {:?}",
1321            change.events
1322        );
1323
1324        // Long quiet: nothing else arrives, and exactly one re-query ran.
1325        tokio::time::advance(Duration::from_secs(5)).await;
1326        settle().await;
1327        assert!(
1328            h.out.try_recv().is_err(),
1329            "burst must coalesce to one change"
1330        );
1331        let stats = h.stats.snapshot();
1332        assert_eq!((stats.requeries, stats.changes), (1, 1));
1333    }
1334
1335    // Signals arriving faster than the quiet window forever: the `max_wait`
1336    // ceiling still forces a re-query at its cadence (the dedicated
1337    // `sleep_until` arm — not just "on the next signal after the deadline").
1338    #[tokio::test(start_paused = true)]
1339    async fn max_wait_caps_continuous_signals() {
1340        let scratch = Scratch::new();
1341        let prev = baseline(&scratch.0, "aaa").await;
1342        let h_config = defaults();
1343        let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, h_config);
1344
1345        // A pump that fires a signal every 100 ms — always inside the 250 ms
1346        // quiet window, so only the ceiling can break the burst.
1347        let pump_sig = h.sig.clone();
1348        let pump = tokio::spawn(async move {
1349            loop {
1350                // `try_send` mirrors the notify callback. `Full` means our previous
1351                // signal is still pending (coalesced) — keep pumping; `Closed` means
1352                // the loop ended — stop.
1353                if let Err(mpsc::error::TrySendError::Closed(WatchSignal::Change)) =
1354                    pump_sig.try_send(WatchSignal::Change)
1355                {
1356                    return;
1357                }
1358                tokio::time::sleep(Duration::from_millis(100)).await;
1359            }
1360        });
1361
1362        let change = tokio::time::timeout(Duration::from_secs(2), h.out.recv())
1363            .await
1364            .expect("the ceiling must fire within max_wait")
1365            .expect("change");
1366        assert!(
1367            change
1368                .events
1369                .iter()
1370                .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1371            "got {:?}",
1372            change.events
1373        );
1374        pump.abort();
1375    }
1376
1377    // P1: a caller "disabling the ceiling" with `Duration::MAX` must not overflow
1378    // the `Instant + max_wait` deadline and panic the spawned loop (which would
1379    // drop the output channel and kill the watcher silently). The clamp keeps it
1380    // running; the debounce timer still fires normally.
1381    #[tokio::test(start_paused = true)]
1382    async fn max_wait_duration_max_does_not_panic_the_loop() {
1383        let scratch = Scratch::new();
1384        let prev = baseline(&scratch.0, "aaa").await;
1385        let config = LoopConfig {
1386            max_wait: Duration::MAX,
1387            ..defaults()
1388        };
1389        let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, config);
1390        h.signal();
1391        tokio::time::advance(Duration::from_millis(300)).await; // past the 250 ms debounce
1392        let change = h
1393            .out
1394            .recv()
1395            .await
1396            .expect("the loop survives a Duration::MAX max_wait and still re-queries");
1397        assert!(!change.events.is_empty(), "got {:?}", change.events);
1398    }
1399
1400    // The base case: one signal, a quiet gap, one re-query.
1401    #[tokio::test(start_paused = true)]
1402    async fn quiet_gap_triggers_requery() {
1403        let scratch = Scratch::new();
1404        let prev = baseline(&scratch.0, "aaa").await;
1405        let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1406
1407        h.signal();
1408        let change = h.out.recv().await.expect("change after the quiet gap");
1409        assert!(
1410            change
1411                .events
1412                .iter()
1413                .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1414        );
1415    }
1416
1417    // A re-query that finds the same state emits nothing — but it *ran* (the
1418    // stats distinguish "no change" from "never re-queried").
1419    #[tokio::test(start_paused = true)]
1420    async fn no_change_yields_no_emission() {
1421        let scratch = Scratch::new();
1422        let prev = baseline(&scratch.0, "aaa").await;
1423        // Same head as the baseline → empty diff.
1424        let mut h = spawn_loop(scripted_repo(&scratch.0, "aaa"), prev, defaults());
1425
1426        h.signal();
1427        settle().await; // let the loop register its quiet timer first
1428        tokio::time::advance(Duration::from_millis(300)).await; // past debounce
1429        settle().await; // let the re-query run
1430
1431        let stats = h.stats.snapshot();
1432        assert_eq!((stats.requeries, stats.changes, stats.skipped), (1, 0, 0));
1433        assert!(
1434            h.out.try_recv().is_err(),
1435            "no events for an unchanged state"
1436        );
1437    }
1438
1439    /// Fails the first `status` call (a transiently held lock), then behaves —
1440    /// `ScriptedRunner` rules are stateless, so the two-phase behaviour needs a
1441    /// tiny stateful runner delegating to throwaway scripted ones.
1442    struct FlakyStatus {
1443        fails_left: AtomicU64,
1444        gitdir: PathBuf,
1445        head: &'static str,
1446    }
1447
1448    #[async_trait::async_trait]
1449    impl ProcessRunner for FlakyStatus {
1450        async fn output_string(
1451            &self,
1452            command: &processkit::Command,
1453        ) -> processkit::Result<processkit::ProcessResult<String>> {
1454            let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1455            if is_status && self.fails_left.load(Ordering::Relaxed) > 0 {
1456                self.fails_left.fetch_sub(1, Ordering::Relaxed);
1457                return Err(processkit::Error::exit(
1458                    "git",
1459                    128,
1460                    "",
1461                    "fatal: Unable to create '.git/index.lock'",
1462                ));
1463            }
1464            scripted(&self.gitdir, self.head)
1465                .output_string(command)
1466                .await
1467        }
1468    }
1469
1470    // A transient re-query failure is skipped (counted, no emission); the next
1471    // signal re-checks and recovers.
1472    #[tokio::test(start_paused = true)]
1473    async fn transient_failure_skips_then_recovers() {
1474        let scratch = Scratch::new();
1475        let prev = baseline(&scratch.0, "aaa").await;
1476        let repo = Box::new(Repo::from_git(
1477            "/r",
1478            "/r",
1479            Git::with_runner(FlakyStatus {
1480                fails_left: AtomicU64::new(1),
1481                gitdir: scratch.0.clone(),
1482                head: "bbb",
1483            }),
1484        ));
1485        let mut h = spawn_loop(repo, prev, defaults());
1486
1487        // First attempt: the snapshot fails → skip, nothing emitted.
1488        h.signal();
1489        settle().await; // loop registers the quiet timer
1490        tokio::time::advance(Duration::from_millis(300)).await;
1491        settle().await; // the (failing) re-query runs
1492        let stats = h.stats.snapshot();
1493        assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
1494        assert_eq!(stats.last_error, Some(WatcherErrorKind::Snapshot));
1495        assert!(h.out.try_recv().is_err());
1496
1497        // Second signal: the lock "cleared" — the re-query recovers and emits.
1498        h.signal();
1499        let change = h.out.recv().await.expect("recovered change");
1500        assert!(
1501            change
1502                .events
1503                .iter()
1504                .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1505        );
1506        let stats = h.stats.snapshot();
1507        assert_eq!((stats.requeries, stats.changes), (2, 1));
1508    }
1509
1510    /// Delays every reply by `delay` (virtual time — `tokio::time::sleep`, NOT a
1511    /// thread sleep, so the paused clock controls it). `ScriptedRunner` replies
1512    /// instantly, so this is the only way to exercise the `requery_timeout`
1513    /// wrapper — a scripted `Reply::timeout()` resolves immediately and would
1514    /// test the *error* path, not the deadline.
1515    struct Sleepy {
1516        delay: Duration,
1517        gitdir: PathBuf,
1518        head: &'static str,
1519    }
1520
1521    #[async_trait::async_trait]
1522    impl ProcessRunner for Sleepy {
1523        async fn output_string(
1524            &self,
1525            command: &processkit::Command,
1526        ) -> processkit::Result<processkit::ProcessResult<String>> {
1527            tokio::time::sleep(self.delay).await;
1528            scripted(&self.gitdir, self.head)
1529                .output_string(command)
1530                .await
1531        }
1532    }
1533
1534    /// Only the first status query is slow. Its timeout drops the sleeping
1535    /// future after the counter has advanced, so the automatic retry succeeds.
1536    struct SlowFirstStatus {
1537        slow_left: AtomicBool,
1538        delay: Duration,
1539        gitdir: PathBuf,
1540        head: &'static str,
1541    }
1542
1543    #[async_trait::async_trait]
1544    impl ProcessRunner for SlowFirstStatus {
1545        async fn output_string(
1546            &self,
1547            command: &processkit::Command,
1548        ) -> processkit::Result<processkit::ProcessResult<String>> {
1549            let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1550            if is_status && self.slow_left.swap(false, Ordering::Relaxed) {
1551                tokio::time::sleep(self.delay).await;
1552            }
1553            scripted(&self.gitdir, self.head)
1554                .output_string(command)
1555                .await
1556        }
1557    }
1558
1559    // A timeout on the final filesystem signal schedules its own retry. No new
1560    // signal is needed to observe the state that the timed-out query missed.
1561    #[tokio::test(start_paused = true)]
1562    async fn timeout_on_last_signal_recovers_via_backoff_retry() {
1563        let scratch = Scratch::new();
1564        let prev = baseline(&scratch.0, "aaa").await;
1565        let repo = Box::new(Repo::from_git(
1566            "/r",
1567            "/r",
1568            Git::with_runner(SlowFirstStatus {
1569                slow_left: AtomicBool::new(true),
1570                delay: Duration::from_secs(10),
1571                gitdir: scratch.0.clone(),
1572                head: "bbb",
1573            }),
1574        ));
1575        let config = LoopConfig {
1576            requery_timeout: Some(Duration::from_secs(5)),
1577            retry_backoff: Duration::from_secs(1),
1578            ..defaults()
1579        };
1580        let mut h = spawn_loop(repo, prev, config);
1581
1582        h.signal();
1583        settle().await;
1584        tokio::time::advance(Duration::from_millis(300)).await;
1585        settle().await;
1586        tokio::time::advance(Duration::from_secs(5)).await;
1587        settle().await;
1588        assert_eq!(
1589            (h.stats.snapshot().requeries, h.stats.snapshot().retries),
1590            (1, 1)
1591        );
1592        assert!(h.out.try_recv().is_err());
1593
1594        // Only virtual time advances here: there is deliberately no h.signal().
1595        tokio::time::advance(Duration::from_secs(1)).await;
1596        settle().await;
1597        let change = h.out.try_recv().expect("retry emits the missed change");
1598        assert!(
1599            change
1600                .events
1601                .iter()
1602                .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1603        );
1604        let stats = h.stats.snapshot();
1605        assert_eq!((stats.requeries, stats.skipped, stats.retries), (2, 1, 1));
1606        assert_eq!((stats.recoveries, stats.changes), (1, 1));
1607    }
1608
1609    // Retry exhaustion is bounded and then becomes idle until another event.
1610    #[tokio::test(start_paused = true)]
1611    async fn persistent_requery_failure_exhausts_retries_without_busy_loop() {
1612        let scratch = Scratch::new();
1613        let prev = baseline(&scratch.0, "aaa").await;
1614        let repo = Box::new(Repo::from_git(
1615            "/r",
1616            "/r",
1617            Git::with_runner(FlakyStatus {
1618                fails_left: AtomicU64::new(100),
1619                gitdir: scratch.0.clone(),
1620                head: "bbb",
1621            }),
1622        ));
1623        let config = LoopConfig {
1624            retry_limit: 2,
1625            retry_backoff: Duration::from_millis(100),
1626            ..defaults()
1627        };
1628        let h = spawn_loop(repo, prev, config);
1629
1630        h.signal();
1631        settle().await;
1632        tokio::time::advance(Duration::from_millis(300)).await;
1633        settle().await;
1634        tokio::time::advance(Duration::from_millis(100)).await;
1635        settle().await;
1636        tokio::time::advance(Duration::from_millis(200)).await;
1637        settle().await;
1638        let stats = h.stats.snapshot();
1639        assert_eq!((stats.requeries, stats.skipped, stats.retries), (3, 3, 2));
1640        assert_eq!(stats.recoveries, 0);
1641
1642        tokio::time::advance(Duration::from_secs(60 * 60)).await;
1643        settle().await;
1644        assert_eq!(
1645            h.stats.snapshot().requeries,
1646            3,
1647            "exhaustion must park on the signal receiver"
1648        );
1649    }
1650
1651    // Closing the producer while parked in backoff cancels the retry promptly.
1652    #[tokio::test(start_paused = true)]
1653    async fn drop_teardown_during_retry_backoff() {
1654        let scratch = Scratch::new();
1655        let prev = baseline(&scratch.0, "aaa").await;
1656        let repo = Box::new(Repo::from_git(
1657            "/r",
1658            "/r",
1659            Git::with_runner(FlakyStatus {
1660                fails_left: AtomicU64::new(1),
1661                gitdir: scratch.0.clone(),
1662                head: "bbb",
1663            }),
1664        ));
1665        let config = LoopConfig {
1666            retry_backoff: Duration::from_secs(60 * 60),
1667            ..defaults()
1668        };
1669        let Harness {
1670            sig,
1671            mut out,
1672            stats,
1673            watch_failed: _,
1674            task,
1675        } = spawn_loop(repo, prev, config);
1676
1677        sig.try_send(WatchSignal::Change).expect("send");
1678        settle().await;
1679        tokio::time::advance(Duration::from_millis(300)).await;
1680        settle().await;
1681        assert_eq!((stats.snapshot().skipped, stats.snapshot().retries), (1, 1));
1682        drop(sig);
1683        task.await.expect("loop exits while retry timer is pending");
1684        assert!(out.recv().await.is_none());
1685    }
1686
1687    // A notify backend death is terminal through the primary API: recv/Stream
1688    // observes channel closure, without requiring separate stats polling.
1689    #[tokio::test(start_paused = true)]
1690    async fn permanent_backend_failure_closes_main_channel() {
1691        let scratch = Scratch::new();
1692        let prev = baseline(&scratch.0, "aaa").await;
1693        let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1694
1695        h.backend_failed();
1696        assert!(h.out.recv().await.is_none(), "backend death closes recv");
1697        let stats = h.stats.snapshot();
1698        assert_eq!((stats.watch_errors, stats.terminal_failures), (1, 1));
1699        assert_eq!((stats.retries, stats.recoveries), (0, 0));
1700    }
1701
1702    // A re-query exceeding the configured deadline is killed and skipped as
1703    // transient; the loop survives (a later attempt runs and is also bounded).
1704    #[tokio::test(start_paused = true)]
1705    async fn requery_timeout_skips_as_transient() {
1706        let scratch = Scratch::new();
1707        let prev = baseline(&scratch.0, "aaa").await;
1708        let repo = Box::new(Repo::from_git(
1709            "/r",
1710            "/r",
1711            Git::with_runner(Sleepy {
1712                delay: Duration::from_secs(10),
1713                gitdir: scratch.0.clone(),
1714                head: "bbb",
1715            }),
1716        ));
1717        let config = LoopConfig {
1718            requery_timeout: Some(Duration::from_secs(5)),
1719            ..defaults()
1720        };
1721        let mut h = spawn_loop(repo, prev, config);
1722
1723        h.signal();
1724        settle().await; // loop registers the quiet timer
1725        tokio::time::advance(Duration::from_millis(300)).await; // debounce
1726        settle().await; // re-query starts; Sleepy + the deadline register timers
1727        tokio::time::advance(Duration::from_secs(6)).await; // past the deadline
1728        settle().await;
1729        let stats = h.stats.snapshot();
1730        assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
1731        assert_eq!(stats.last_error, Some(WatcherErrorKind::Timeout));
1732        assert!(h.out.try_recv().is_err());
1733
1734        // The loop is alive: a second attempt runs (and times out the same way).
1735        h.signal();
1736        settle().await;
1737        tokio::time::advance(Duration::from_millis(300)).await;
1738        settle().await;
1739        tokio::time::advance(Duration::from_secs(6)).await;
1740        settle().await;
1741        assert_eq!(h.stats.snapshot().requeries, 2);
1742    }
1743
1744    // R4: the startup baseline honors `requery_timeout` — a snapshot that wedges (a
1745    // `Sleepy` repo far past the deadline) errors with `TimedOut` instead of hanging
1746    // `build()` forever. Exercises `capture_baseline` directly (the `build()` path is
1747    // only reachable with a real notify watcher).
1748    #[tokio::test(start_paused = true)]
1749    async fn baseline_capture_honors_requery_timeout() {
1750        let scratch = Scratch::new();
1751        let repo = Repo::from_git(
1752            "/r",
1753            "/r",
1754            Git::with_runner(Sleepy {
1755                delay: Duration::from_secs(10),
1756                gitdir: scratch.0.clone(),
1757                head: "bbb",
1758            }),
1759        );
1760        let err = capture_baseline(&repo, Some(Duration::from_secs(5)), false)
1761            .await
1762            .expect_err("a wedged baseline must time out, not hang");
1763        assert!(
1764            matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::TimedOut),
1765            "expected an Io TimedOut, got {err:?}"
1766        );
1767        // A wedged baseline is retryable — `build()` agrees with the loop's transient
1768        // treatment of a re-query timeout.
1769        assert!(err.is_transient(), "a baseline timeout is transient");
1770
1771        // With no deadline the same query completes (Sleepy still returns, just late);
1772        // advancing the clock lets it finish so we prove the timeout — not the repo —
1773        // is what produced the error above.
1774        let ok = capture_baseline(&repo, None, false).await;
1775        assert!(ok.is_ok(), "an unbounded baseline still succeeds: {ok:?}");
1776    }
1777
1778    // Closing the signal channel mid-debounce ends the loop promptly and closes
1779    // the output channel.
1780    #[tokio::test(start_paused = true)]
1781    async fn drop_teardown_mid_debounce() {
1782        let scratch = Scratch::new();
1783        let prev = baseline(&scratch.0, "aaa").await;
1784        let Harness {
1785            sig,
1786            mut out,
1787            stats: _,
1788            watch_failed: _,
1789            task,
1790        } = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1791
1792        sig.try_send(WatchSignal::Change).expect("send");
1793        tokio::time::advance(Duration::from_millis(100)).await; // mid-debounce
1794        drop(sig);
1795
1796        tokio::time::timeout(Duration::from_secs(1), task)
1797            .await
1798            .expect("loop ends promptly")
1799            .expect("loop task joins cleanly");
1800        assert!(out.recv().await.is_none(), "output closes with the loop");
1801    }
1802
1803    /// Reports a different head on every `status` call, so every re-query
1804    /// produces a `HeadMoved` — the emission generator the backpressure test
1805    /// needs to fill the bounded output channel.
1806    struct VaryingHead {
1807        statuses: AtomicU64,
1808        gitdir: PathBuf,
1809    }
1810
1811    #[async_trait::async_trait]
1812    impl ProcessRunner for VaryingHead {
1813        async fn output_string(
1814            &self,
1815            command: &processkit::Command,
1816        ) -> processkit::Result<processkit::ProcessResult<String>> {
1817            let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1818            let n = if is_status {
1819                self.statuses.fetch_add(1, Ordering::Relaxed)
1820            } else {
1821                self.statuses.load(Ordering::Relaxed)
1822            };
1823            scripted(&self.gitdir, &format!("h{n}"))
1824                .output_string(command)
1825                .await
1826        }
1827    }
1828
1829    // A full output channel parks the loop at `send` (backpressure) instead of
1830    // dropping or buffering unboundedly; draining one item unparks it.
1831    #[tokio::test(start_paused = true)]
1832    async fn backpressure_parks_loop() {
1833        let scratch = Scratch::new();
1834        let prev = baseline(&scratch.0, "base").await;
1835        let repo = Box::new(Repo::from_git(
1836            "/r",
1837            "/r",
1838            Git::with_runner(VaryingHead {
1839                statuses: AtomicU64::new(0),
1840                gitdir: scratch.0.clone(),
1841            }),
1842        ));
1843        let config = LoopConfig {
1844            output_capacity: 1,
1845            ..defaults()
1846        };
1847        let mut h = spawn_loop(repo, prev, config);
1848
1849        // First change fills the capacity-1 channel.
1850        h.signal();
1851        settle().await; // loop registers the quiet timer
1852        tokio::time::advance(Duration::from_millis(300)).await;
1853        settle().await; // re-query runs; emission 1 fills the channel
1854        // Second re-query produces another change; the send parks (channel full):
1855        // the re-query ran but the emission hasn't landed.
1856        h.signal();
1857        settle().await;
1858        tokio::time::advance(Duration::from_millis(300)).await;
1859        settle().await;
1860        let stats = h.stats.snapshot();
1861        assert_eq!(
1862            (stats.requeries, stats.changes),
1863            (2, 1),
1864            "second emission must be parked on the full channel"
1865        );
1866
1867        // Draining unparks the loop; both changes arrive in order.
1868        let first = h.out.recv().await.expect("first change");
1869        assert!(
1870            first
1871                .events
1872                .iter()
1873                .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1874        );
1875        let second = h.out.recv().await.expect("second change");
1876        assert!(
1877            second
1878                .events
1879                .iter()
1880                .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1881        );
1882        settle().await;
1883        assert_eq!(h.stats.snapshot().changes, 2);
1884    }
1885
1886    // The `stream` feature: `StreamExt::next` on the REAL `RepoWatcher` yields
1887    // what `recv` would and advances `current()` identically. The watcher is
1888    // assembled directly (same crate) around the loop harness's channel, with an
1889    // idle notify watcher standing in for the OS watch.
1890    #[cfg(feature = "stream")]
1891    #[tokio::test(start_paused = true)]
1892    async fn stream_yields_changes_and_advances_current() {
1893        use tokio_stream::StreamExt;
1894
1895        let scratch = Scratch::new();
1896        let prev = baseline(&scratch.0, "aaa").await;
1897        let h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1898
1899        let baseline_snap = scripted_repo(&scratch.0, "aaa")
1900            .snapshot()
1901            .await
1902            .expect("baseline snapshot");
1903        let mut watcher = RepoWatcher {
1904            rx: h.out,
1905            current: baseline_snap,
1906            stats: h.stats,
1907            _watcher: notify::recommended_watcher(|_res| {}).expect("idle watcher"),
1908            task: h.task,
1909        };
1910        assert_eq!(watcher.current().head.as_deref(), Some("aaa"));
1911
1912        // `h` is partially moved into `watcher` above, so reach the remaining `sig`
1913        // field directly rather than through the `h.signal()` method (which would
1914        // borrow all of `h`).
1915        let _ = h.sig.try_send(WatchSignal::Change);
1916        let change = watcher.next().await.expect("stream item");
1917        assert!(
1918            change
1919                .events
1920                .iter()
1921                .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1922            "got {:?}",
1923            change.events
1924        );
1925        // Polling through the Stream advanced `current()` exactly like `recv`.
1926        assert_eq!(watcher.current().head.as_deref(), Some("bbb"));
1927    }
1928}
1929
1930// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
1931#[doc = include_str!("../docs/watch.md")]
1932#[allow(rustdoc::broken_intra_doc_links)]
1933pub mod guide {}