vcs_core/error.rs
1//! The facade's error type: a thin wrapper that adds repo-detection failures on
2//! top of the underlying [`processkit::Error`] the per-tool clients return.
3//!
4//! The [`Error::Vcs`] variant carries a [`processkit::Error`] verbatim — re-exported
5//! at the crate root (`vcs_core::processkit`) so you can match it without a direct
6//! `processkit` dependency. Prefer the `is_*` classifiers ([`is_merge_conflict`](Error::is_merge_conflict)
7//! / [`is_nothing_to_commit`](Error::is_nothing_to_commit) /
8//! [`is_transient_fetch_error`](Error::is_transient_fetch_error) /
9//! [`is_transient`](Error::is_transient) / [`is_not_found`](Error::is_not_found))
10//! to branch on intent rather than matching the wrapped error's internals.
11
12use std::path::PathBuf;
13
14/// An error from a [`Repo`](crate::Repo) operation.
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum Error {
18 /// [`Repo::open`](crate::Repo::open) found no `.git`/`.jj` from the start dir
19 /// up to the filesystem root.
20 NotARepository(PathBuf),
21 /// A worktree/workspace lookup by path matched no attached worktree.
22 WorktreeNotFound(PathBuf),
23 /// A filesystem operation failed (e.g. removing a workspace directory).
24 Io(std::io::Error),
25 /// An underlying `vcs-git` / `vcs-jj` (i.e. `processkit`) error.
26 Vcs(processkit::Error),
27}
28
29impl Error {
30 /// Whether this wraps a merge/rebase **conflict** from the backend — so a
31 /// caller can branch on "conflict, resolve it" vs. a hard failure without
32 /// matching on [`processkit::Error`] internals. (Recognises git's conflict
33 /// markers; jj surfaces conflicts as state, not errors — see
34 /// [`Repo::in_progress_state`](crate::Repo::in_progress_state).)
35 ///
36 /// Named to match the wrapper classifiers
37 /// ([`vcs_cli_support::is_merge_conflict`]) — one name per concept across the
38 /// workspace.
39 pub fn is_merge_conflict(&self) -> bool {
40 matches!(self, Error::Vcs(e) if vcs_cli_support::is_merge_conflict(e))
41 }
42
43 /// Whether this is a benign "nothing to commit" — an empty commit attempt the
44 /// caller likely wants to treat as a no-op.
45 pub fn is_nothing_to_commit(&self) -> bool {
46 matches!(self, Error::Vcs(e) if vcs_cli_support::is_nothing_to_commit(e))
47 }
48
49 /// Whether this is a **transient** fetch/network failure worth retrying — DNS, a
50 /// dropped connection, a fast blip. A **timeout is not** transient (it already
51 /// spent the full deadline; retrying would multiply the wall-clock — see
52 /// [`vcs_cli_support::is_transient_fetch_error`]). The underlying clients already
53 /// retry their own fetches; this is for retrying higher-level flows.
54 pub fn is_transient_fetch_error(&self) -> bool {
55 matches!(self, Error::Vcs(e) if vcs_cli_support::is_transient_fetch_error(e))
56 }
57
58 /// Whether the underlying error is a **transient io/spawn** failure
59 /// (interrupted / would-block / resource-busy) — delegates to
60 /// [`processkit::Error::is_transient`]. Narrower than
61 /// [`is_transient_fetch_error`](Error::is_transient_fetch_error) (which also
62 /// treats the network markers as retryable — but not a timeout); use this to retry
63 /// *any* operation past a momentary io hiccup. The facade's own
64 /// [`Io`](Error::Io)/[`NotARepository`](Error::NotARepository)/
65 /// [`WorktreeNotFound`](Error::WorktreeNotFound) variants are never transient.
66 pub fn is_transient(&self) -> bool {
67 matches!(self, Error::Vcs(e) if e.is_transient())
68 }
69
70 /// Whether the underlying CLI binary (`git`/`jj`) **wasn't found** — a setup
71 /// problem (the tool isn't installed or isn't on `PATH`), not a repository or
72 /// usage error. Delegates to [`processkit::Error::is_not_found`]; lets a caller
73 /// surface a "please install git/jj" hint instead of a raw spawn failure.
74 pub fn is_not_found(&self) -> bool {
75 matches!(self, Error::Vcs(e) if e.is_not_found())
76 }
77
78 /// Whether this is an **input rejection** — a value the facade refused *before*
79 /// spawning, because it was a bad argument: a flag-like/empty/NUL-containing
80 /// value in a guarded positional slot (via the wrapper guards), or a facade-level
81 /// precondition on the arguments (an empty file set for `commit_paths`, removing
82 /// the main workspace). This is a **caller bug**, distinct from a real IO or
83 /// backend failure — a language binding maps it to a `ValueError`. Completes the
84 /// `is_*` classifier family alongside [`is_not_found`](Error::is_not_found).
85 pub fn is_invalid_input(&self) -> bool {
86 match self {
87 Error::Io(e) => e.kind() == std::io::ErrorKind::InvalidInput,
88 Error::Vcs(e) => vcs_cli_support::is_invalid_input(e),
89 _ => false,
90 }
91 }
92
93 /// Whether a **resource the operation named doesn't exist** — currently a
94 /// worktree/workspace lookup by path that matched no attached worktree
95 /// ([`WorktreeNotFound`](Error::WorktreeNotFound)). Distinct from
96 /// [`is_not_found`](Error::is_not_found), which means the `git`/`jj` **binary**
97 /// wasn't found (a setup problem), and from [`is_invalid_input`](Error::is_invalid_input)
98 /// (a bad argument). A binding maps this to a `NotFoundError`.
99 ///
100 /// Note the backend asymmetry: only the **jj** backend raises the typed
101 /// `WorktreeNotFound`; git's missing-worktree removal surfaces as a generic
102 /// backend `Exit`, which this does not classify. (Likewise the main-workspace
103 /// refusal that [`is_invalid_input`](Error::is_invalid_input) recognizes is a
104 /// typed error only on jj.)
105 pub fn is_resource_not_found(&self) -> bool {
106 matches!(self, Error::WorktreeNotFound(_))
107 }
108}
109
110impl std::fmt::Display for Error {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 Error::NotARepository(p) => {
114 write!(
115 f,
116 "no git or jj repository found at or above {}",
117 p.display()
118 )
119 }
120 Error::WorktreeNotFound(p) => {
121 write!(f, "no worktree found at {}", p.display())
122 }
123 Error::Io(e) => write!(f, "{e}"),
124 Error::Vcs(e) => write!(f, "{e}"),
125 }
126 }
127}
128
129impl std::error::Error for Error {
130 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
131 match self {
132 Error::Io(e) => Some(e),
133 Error::Vcs(e) => Some(e),
134 _ => None,
135 }
136 }
137}
138
139impl From<std::io::Error> for Error {
140 fn from(e: std::io::Error) -> Self {
141 Error::Io(e)
142 }
143}
144
145impl From<processkit::Error> for Error {
146 fn from(e: processkit::Error) -> Self {
147 Error::Vcs(e)
148 }
149}
150
151/// `Result` specialised to the facade [`Error`].
152pub type Result<T> = std::result::Result<T, Error>;
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn is_transient_delegates_to_processkit_and_excludes_facade_variants() {
160 // An interrupted spawn is a transient io failure.
161 let interrupted = Error::Vcs(processkit::Error::Spawn {
162 program: "git".into(),
163 source: std::io::Error::from(std::io::ErrorKind::Interrupted),
164 });
165 assert!(interrupted.is_transient());
166 // A missing binary is NOT transient (retrying won't install it).
167 let missing = Error::Vcs(processkit::Error::Spawn {
168 program: "git".into(),
169 source: std::io::Error::from(std::io::ErrorKind::NotFound),
170 });
171 assert!(!missing.is_transient());
172 // The facade's own io/detection variants are never transient.
173 assert!(!Error::Io(std::io::Error::from(std::io::ErrorKind::Interrupted)).is_transient());
174 assert!(!Error::NotARepository("/x".into()).is_transient());
175 }
176
177 #[test]
178 fn is_not_found_only_for_a_missing_binary() {
179 let not_found = Error::Vcs(processkit::Error::NotFound {
180 program: "jj".into(),
181 searched: None,
182 });
183 assert!(not_found.is_not_found());
184 // An ordinary non-zero exit is not a "binary not found".
185 let exit = Error::Vcs(processkit::Error::Exit {
186 program: "git".into(),
187 code: 1,
188 stdout: String::new(),
189 stderr: "fatal: not a git repository".into(),
190 });
191 assert!(!exit.is_not_found());
192 assert!(!Error::NotARepository("/x".into()).is_not_found());
193 }
194
195 #[test]
196 fn is_invalid_input_for_guard_rejections_and_facade_input_errors() {
197 // A wrapper guard rejection (flag-like positional) surfaces as invalid input.
198 let guarded = Error::Vcs(processkit::Error::Spawn {
199 program: "git".into(),
200 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "flag-like"),
201 });
202 assert!(guarded.is_invalid_input());
203 // The facade's own `Io(InvalidInput)` guard (e.g. an empty commit set) too.
204 assert!(
205 Error::Io(std::io::Error::from(std::io::ErrorKind::InvalidInput)).is_invalid_input()
206 );
207 // A real spawn failure, a detection error, and a generic io error are NOT.
208 assert!(
209 !Error::Vcs(processkit::Error::Spawn {
210 program: "git".into(),
211 source: std::io::Error::from(std::io::ErrorKind::NotFound),
212 })
213 .is_invalid_input()
214 );
215 assert!(!Error::NotARepository("/x".into()).is_invalid_input());
216 assert!(!Error::Io(std::io::Error::other("disk full")).is_invalid_input());
217 }
218
219 #[test]
220 fn is_resource_not_found_only_for_a_worktree_lookup() {
221 assert!(Error::WorktreeNotFound("/wt".into()).is_resource_not_found());
222 // The *binary* missing is a different classifier (is_not_found), and a bad
223 // repo path is neither.
224 let missing_bin = Error::Vcs(processkit::Error::NotFound {
225 program: "jj".into(),
226 searched: None,
227 });
228 assert!(missing_bin.is_not_found() && !missing_bin.is_resource_not_found());
229 assert!(!Error::NotARepository("/x".into()).is_resource_not_found());
230 }
231}