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