vcs_watch/error.rs
1//! The crate's error type: filesystem-watcher setup failures plus the underlying
2//! `vcs-core` re-query errors.
3//!
4//! The filesystem-watch backend (`notify`) is a **private** dependency: its
5//! failures surface as the opaque [`WatchError`], classified through this crate's
6//! own stable methods rather than by matching the third-party error type. So a
7//! consumer reads and source-chains a watch failure through `vcs-watch` alone —
8//! no direct `notify` dependency to keep version-matched — and a `notify` major
9//! bump stays an *internal*, non-breaking change here (the backend is not part of
10//! this crate's stability contract).
11
12use std::path::PathBuf;
13
14/// An error from setting up or running a [`RepoWatcher`](crate::RepoWatcher).
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum Error {
18 /// The filesystem watcher failed to start, or to register/deregister a
19 /// watched path. Opaque over the private backend — inspect it through
20 /// [`WatchError`]'s classifiers (reachable directly, or via
21 /// [`Error::watch_error`]) instead of the backend's own error type.
22 Notify(WatchError),
23 /// A `vcs-core` query (detection / `snapshot` / `local_branches`) failed —
24 /// chiefly while *building* the watcher (capturing the baseline state). A
25 /// re-query failure *during* watching is skipped and retried, not surfaced
26 /// here (see [`RepoWatcher`](crate::RepoWatcher)).
27 Vcs(vcs_core::Error),
28 /// A filesystem operation failed (e.g. resolving a worktree gitlink).
29 Io(std::io::Error),
30}
31
32impl Error {
33 /// Whether this wraps a **transient** failure worth retrying — an
34 /// interrupted / would-block / resource-busy io/spawn failure from the
35 /// underlying `vcs-core` query (delegates to
36 /// [`vcs_core::Error::is_transient`]), **or** a baseline-snapshot **timeout**
37 /// (`Io` `TimedOut`, raised when the startup snapshot exceeds
38 /// `requery_timeout`) — a wedged repo may un-wedge, and the loop already treats
39 /// a re-query timeout as a transient skip, so `build()` agrees. Other `Io` and
40 /// `Notify` errors are `false` (a failed OS watch registration won't fix itself
41 /// on a blind retry — classify it via [`WatchError`] and act on the cause).
42 /// Mirrors the classifier family on the other facades.
43 pub fn is_transient(&self) -> bool {
44 match self {
45 Error::Vcs(e) => e.is_transient(),
46 Error::Io(e) => e.kind() == std::io::ErrorKind::TimedOut,
47 _ => false,
48 }
49 }
50
51 /// Whether the underlying VCS binary (`git`/`jj`) **wasn't found** — a setup
52 /// problem (not installed / not on `PATH`), surfaced while building the
53 /// watcher's baseline. Delegates to [`vcs_core::Error::is_not_found`].
54 pub fn is_not_found(&self) -> bool {
55 matches!(self, Error::Vcs(e) if e.is_not_found())
56 }
57
58 /// The opaque [`WatchError`] when this is a filesystem-watch backend failure;
59 /// `None` for a `Vcs`/`Io` error. A stable accessor (mirroring
60 /// [`processkit_error`](Self::processkit_error)) so a caller — or a language
61 /// binding — can reach the watch classifiers without matching the enum
62 /// variant by hand.
63 pub fn watch_error(&self) -> Option<&WatchError> {
64 match self {
65 Error::Notify(e) => Some(e),
66 _ => None,
67 }
68 }
69
70 /// The structured underlying [`processkit::Error`], if this error came from a
71 /// VCS subprocess — flattening the two-level
72 /// `Vcs(`[`vcs_core::Error::Vcs`]`(_))` nesting so a caller (or a language
73 /// binding) can read its structured fields (`program`, plus `code`/`stdout`/
74 /// `stderr` on an `Exit`) without hand-walking it. `None` for a `Notify`/`Io`
75 /// failure or a non-subprocess `vcs-core` error (e.g. "not a repository").
76 pub fn processkit_error(&self) -> Option<&processkit::Error> {
77 match self {
78 Error::Vcs(vcs_core::Error::Vcs(e)) => Some(e),
79 _ => None,
80 }
81 }
82}
83
84impl std::fmt::Display for Error {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Error::Notify(e) => write!(f, "filesystem watch failed: {e}"),
88 Error::Vcs(e) => write!(f, "{e}"),
89 Error::Io(e) => write!(f, "{e}"),
90 }
91 }
92}
93
94impl std::error::Error for Error {
95 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96 match self {
97 Error::Notify(e) => Some(e),
98 Error::Vcs(e) => Some(e),
99 Error::Io(e) => Some(e),
100 }
101 }
102}
103
104impl From<notify::Error> for Error {
105 fn from(e: notify::Error) -> Self {
106 Error::Notify(WatchError(e))
107 }
108}
109
110impl From<vcs_core::Error> for Error {
111 fn from(e: vcs_core::Error) -> Self {
112 Error::Vcs(e)
113 }
114}
115
116impl From<std::io::Error> for Error {
117 fn from(e: std::io::Error) -> Self {
118 Error::Io(e)
119 }
120}
121
122/// An opaque filesystem-watch backend failure — the watcher couldn't start, or
123/// couldn't register/deregister a watched path.
124///
125/// It wraps the crate's **private** watch backend so a consumer can *classify*
126/// and *source-chain* the failure without naming — or depending on — that
127/// third-party crate:
128///
129/// - [`is_path_not_found`](Self::is_path_not_found) — the watched `.git`/`.jj`
130/// directory does not exist (removed, or never present);
131/// - [`is_watch_limit`](Self::is_watch_limit) — the OS watch-descriptor limit was
132/// reached (e.g. Linux inotify's `max_user_watches`);
133/// - [`io_error`](Self::io_error) — the raw [`std::io::Error`] when the backend
134/// failure was an I/O one (also reachable via
135/// [`std::error::Error::source`]);
136/// - [`paths`](Self::paths) — the paths the backend blamed, if any.
137///
138/// Because the backend type never appears in a public signature, a backend major
139/// bump is an internal change here — not a breaking one downstream. Obtain one
140/// from [`Error::watch_error`] or by matching [`Error::Notify`].
141#[derive(Debug)]
142pub struct WatchError(notify::Error);
143
144impl WatchError {
145 /// The watched path does not exist — e.g. the `.git`/`.jj` state directory
146 /// was removed (or never existed), so the OS watch can't be registered until
147 /// the path is present.
148 pub fn is_path_not_found(&self) -> bool {
149 matches!(self.0.kind, notify::ErrorKind::PathNotFound)
150 }
151
152 /// The OS watch-descriptor limit was reached (e.g. Linux inotify's
153 /// `max_user_watches`) — the watch can't be registered until the limit is
154 /// raised or other watches are released. Best-effort / platform-dependent.
155 pub fn is_watch_limit(&self) -> bool {
156 matches!(self.0.kind, notify::ErrorKind::MaxFilesWatch)
157 }
158
159 /// The underlying [`std::io::Error`] when the backend failure was an I/O
160 /// error (`None` otherwise) — inspect its [`kind`](std::io::Error::kind)
161 /// without walking the [`source`](std::error::Error::source) chain.
162 pub fn io_error(&self) -> Option<&std::io::Error> {
163 match &self.0.kind {
164 notify::ErrorKind::Io(e) => Some(e),
165 _ => None,
166 }
167 }
168
169 /// The paths the backend associated with this failure — empty for a general,
170 /// path-less failure.
171 pub fn paths(&self) -> &[PathBuf] {
172 &self.0.paths
173 }
174}
175
176impl std::fmt::Display for WatchError {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 std::fmt::Display::fmt(&self.0, f)
179 }
180}
181
182impl std::error::Error for WatchError {
183 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
184 // The backend only overrides the deprecated `cause()`, so re-expose the
185 // underlying io error via the modern `source()` for a caller walking the
186 // chain (`Error` -> `WatchError` -> `io::Error`).
187 match &self.0.kind {
188 notify::ErrorKind::Io(e) => Some(e),
189 _ => None,
190 }
191 }
192}
193
194/// `Result` specialised to the watcher [`Error`].
195pub type Result<T> = std::result::Result<T, Error>;
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 /// The classifiers delegate through the `Vcs(vcs_core::Error)` layer and the
202 /// accessor flattens the two-level nesting; non-VCS errors are inert.
203 #[test]
204 fn classifiers_and_accessor_reach_through_the_vcs_layer() {
205 // A transient io/spawn hiccup from the underlying vcs-core query.
206 let transient = Error::Vcs(vcs_core::Error::Vcs(processkit::Error::spawn(
207 "git",
208 std::io::Error::from(std::io::ErrorKind::Interrupted),
209 )));
210 assert!(transient.is_transient(), "interrupted spawn is transient");
211 assert!(!transient.is_not_found());
212 assert!(
213 transient.processkit_error().is_some(),
214 "reaches the inner error"
215 );
216 assert!(
217 transient.watch_error().is_none(),
218 "a vcs-core error is not a watch error"
219 );
220
221 // The VCS binary wasn't found (setup problem), not transient.
222 let missing = Error::Vcs(vcs_core::Error::Vcs(processkit::Error::not_found(
223 "jj", None,
224 )));
225 assert!(missing.is_not_found(), "missing binary is not-found");
226 assert!(!missing.is_transient());
227 assert!(missing.processkit_error().is_some());
228
229 // A generic filesystem (`Io`) failure is neither, and carries no process error.
230 let io = Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
231 assert!(!io.is_transient() && !io.is_not_found());
232 assert!(
233 io.processkit_error().is_none(),
234 "no subprocess behind an Io error"
235 );
236
237 // ...but a baseline-snapshot timeout (`Io` `TimedOut`, R4) IS transient — a
238 // wedged repo may un-wedge, so `build()` is worth retrying.
239 let baseline_timeout = Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
240 assert!(
241 baseline_timeout.is_transient(),
242 "a baseline TimedOut is transient (retryable)"
243 );
244 }
245
246 /// The opaque `WatchError` classifies each backend failure kind and
247 /// source-chains its io cause — all without re-exposing the backend type.
248 #[test]
249 fn watch_error_classifies_backend_kinds() {
250 // PathNotFound: the watched state dir is gone. `paths` carries the blame.
251 let e: Error = notify::Error::path_not_found()
252 .add_path(PathBuf::from("/repo/.git"))
253 .into();
254 let w = e
255 .watch_error()
256 .expect("a Notify error exposes its WatchError");
257 assert!(w.is_path_not_found());
258 assert!(!w.is_watch_limit());
259 assert!(w.io_error().is_none());
260 assert_eq!(w.paths(), [PathBuf::from("/repo/.git")]);
261 // No io cause for a path-not-found, and the enum classifiers stay inert.
262 assert!(std::error::Error::source(w).is_none());
263 assert!(!e.is_transient() && !e.is_not_found());
264 assert!(e.processkit_error().is_none());
265
266 // MaxFilesWatch: the inotify / descriptor limit.
267 let limit: Error = notify::Error::new(notify::ErrorKind::MaxFilesWatch).into();
268 let w = limit.watch_error().expect("WatchError");
269 assert!(w.is_watch_limit() && !w.is_path_not_found());
270
271 // Io: the raw error is reachable directly and via `source()`.
272 let io: Error =
273 notify::Error::io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)).into();
274 let w = io.watch_error().expect("WatchError");
275 assert_eq!(
276 w.io_error().map(|e| e.kind()),
277 Some(std::io::ErrorKind::PermissionDenied)
278 );
279 let src = std::error::Error::source(w).expect("io cause is source-chained");
280 assert!(src.downcast_ref::<std::io::Error>().is_some());
281 }
282
283 /// The top-level `Error` source chain reaches the io cause *through* the
284 /// opaque wrapper: `Error` -> `WatchError` -> `io::Error`.
285 #[test]
286 fn top_level_source_chain_reaches_io_through_watch_error() {
287 let e: Error = notify::Error::io(std::io::Error::from(std::io::ErrorKind::NotFound)).into();
288 let first = std::error::Error::source(&e).expect("WatchError is the first source");
289 assert!(
290 first.downcast_ref::<WatchError>().is_some(),
291 "the opaque wrapper is the immediate source"
292 );
293 let second = first.source().expect("io::Error is the next link");
294 assert!(second.downcast_ref::<std::io::Error>().is_some());
295 }
296}