gunnar_store/error.rs
1//! The one error type this crate reports through.
2//!
3//! # What left with `ObjectStore` and `RefStore`, and what it cost
4//!
5//! This type used to carry `RefCas { name, expected, actual }` and the
6//! `Observed` three-state beside it, and `Error::is_cas_failure()` was how
7//! `gunnar-wire`'s receive-pack told *"another push beat you to it"* apart from
8//! *"the disk is broken"*.
9//!
10//! [`GitOps::update_ref`](git_storage_trait::GitOps::update_ref) returns
11//! `anyhow::Result<TxId>`. A compare-and-swap rejection through the frozen
12//! contract is therefore **untyped**, and a caller can no longer distinguish a
13//! lost race from a backend fault without reading a message. That is a real
14//! narrowing and it is recorded here as a fact rather than papered over: the
15//! contract is frozen by default, changing it is a
16//! two-implementation decision, and a backend whose honest answer is narrower
17//! than the caller wants records that rather than editing the trait.
18//!
19//! The variants below are the ones this crate can still *raise*: name rules
20//! ([`crate::refname`]), the Arrow secret log's two, and the three plumbing
21//! variants everything needs.
22
23/// The crate-wide result type.
24pub type Result<T, E = Error> = std::result::Result<T, E>;
25
26/// Everything `gunnar-store` itself can go wrong with.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum Error {
30 /// A reference name is not a legal git reference name.
31 ///
32 /// Raised by [`crate::refname::validate`]. A name with a `..` component in
33 /// it is how a path sandbox gets walked out of, so this is a security
34 /// boundary as much as a correctness one.
35 #[error("invalid reference name {name:?}: {reason}")]
36 InvalidRefName {
37 /// The offending name, lossily decoded for display.
38 name: String,
39 /// Why it was rejected.
40 reason: &'static str,
41 },
42
43 /// **Two reference names cannot both exist: one is a directory prefix of
44 /// the other.**
45 ///
46 /// `refs/heads/x` and `refs/heads/x/y` are a file and a directory of the
47 /// same name in a loose-ref backend, and git refuses the pair on *every*
48 /// backend so a repository stays convertible between them. Nothing raced and
49 /// retrying will never help, which is why it is a variant of its own.
50 ///
51 /// Measured, git 2.53.0, `git push` into a bare repository: the per-ref
52 /// answer is `! [remote rejected] (refname conflict)`, and when both names
53 /// arrive in one push the server refuses the batch with `cannot process
54 /// 'refs/heads/x' and 'refs/heads/x/y' at the same time`.
55 #[error("refname conflict: {name} cannot exist while {conflicts_with} does")]
56 RefNameConflict {
57 /// The reference the transaction tried to write.
58 name: String,
59 /// The reference already present, or written by the same transaction,
60 /// that is a directory prefix of `name` or has `name` as one.
61 conflicts_with: String,
62 },
63
64 /// An object's stored bytes could not be decoded.
65 #[error("object {oid} is corrupt: {reason}")]
66 CorruptObject {
67 /// The object that failed to decode.
68 oid: gix_hash::ObjectId,
69 /// Why decoding failed.
70 reason: String,
71 },
72
73 /// An object that something else points at is not in the store.
74 ///
75 /// Raised while walking a reference's closure: a repository that cannot
76 /// serve one of its own refs. Not a fault of the caller's request, which is
77 /// why it is not an [`Error::Backend`].
78 #[error("object {id} is referenced{} but is not in the store", match referrer {
79 Some(from) => format!(" by {from}"),
80 None => String::new(),
81 })]
82 MissingObject {
83 /// The object that could not be found.
84 id: gix_hash::ObjectId,
85 /// What pointed at it, when that is known.
86 referrer: Option<gix_hash::ObjectId>,
87 },
88
89 /// The store was opened with one hash kind but is holding another.
90 ///
91 /// A sha1 repo and a sha256 repo are different repos; silently mixing them
92 /// would produce oids that no client can verify.
93 #[error("hash kind mismatch: store holds {stored}, was opened as {requested}")]
94 HashKindMismatch {
95 /// What the store already contains.
96 stored: gix_hash::Kind,
97 /// What the caller asked for.
98 requested: gix_hash::Kind,
99 },
100
101 /// Another writer already holds this store's exclusive lock.
102 ///
103 /// Compare-and-swap over an append-only log is only sound with one writer:
104 /// two appenders at the end of an Arrow IPC stream interleave and destroy
105 /// the frame boundaries, and the format has no reservation protocol. A
106 /// second gunnar therefore **refuses to start** (the Arrow-IPC ref log).
107 ///
108 /// Since the liveness check this means the holder is **alive**, or that it could not
109 /// be *shown* to be gone: no `/proc`, an unreadable owner line, or a lock
110 /// written by a gunnar that recorded no start time. A lock whose owner is
111 /// provably gone is reclaimed by
112 /// `ArrowLogStore::open` itself and never
113 /// produces this error. `ArrowLogStore::force_unlock`
114 /// is for the undecidable remainder.
115 #[error(
116 "the store is locked by another writer ({}); its owner is still running, or could \
117 not be shown to be gone. Clear the lock only if no gunnar is running against it",
118 path.display()
119 )]
120 StoreLocked {
121 /// The lock file that is already present.
122 path: std::path::PathBuf,
123 },
124
125 /// Hashing failed. Since gix-hash 0.27 (gix 0.88, 2026-10-02) hashing
126 /// reports a `gix_error` exception instead of `hasher::Error`; it is kept
127 /// here as a `gix_error::Error`.
128 #[error("could not compute an object hash")]
129 Hash(#[source] gix_error::Error),
130
131 /// I/O failed.
132 #[error(transparent)]
133 Io(#[from] std::io::Error),
134
135 /// A backend-specific failure that has no gunnar-level meaning.
136 #[error(transparent)]
137 Backend(#[from] anyhow::Error),
138}
139
140impl Error {
141 /// `true` if two names were refused because they cannot coexist.
142 ///
143 /// Kept as a predicate rather than left to a `matches!` at each call site
144 /// because receive-pack reports it to the client as a *refname conflict*,
145 /// which is a different answer from a non-fast-forward and from an internal
146 /// error, and a retry will never change it.
147 pub fn is_name_conflict(&self) -> bool {
148 matches!(self, Error::RefNameConflict { .. })
149 }
150
151 /// Build a [`Error::Backend`] from anything printable.
152 pub fn backend(msg: impl std::fmt::Display) -> Self {
153 Error::Backend(anyhow::anyhow!("{msg}"))
154 }
155}
156
157// `impl From<arrow_schema::ArrowError> for Error` used to be here, generated by
158// a `from_via_anyhow!` macro, so that the Arrow log's `?` converted implicitly.
159// It went with the Arrow log on 2026-08-10: it was the last `arrow-*` edge in
160// this crate, and this crate is what both `GitOps` backends dev-depend on from
161// two other repositories to run the conformance suite. One `From` impl is not
162// worth making znippy resolve `arrow-schema`. `gunnar_core::arrow_log::arrow_err`
163// is the single replacement, and it is one function rather than a `map_err`
164// closure per call site.
165
166/// Every gix call that reports `Exn<Message>` inside this crate is a hash
167/// computation (`compute_hash`, `Hasher::try_finalize`), so `?` lands in
168/// [`Error::Hash`] exactly as `#[from] gix_hash::hasher::Error` did before gix 0.88.
169impl From<gix_error::Exn<gix_error::Message>> for Error {
170 fn from(err: gix_error::Exn<gix_error::Message>) -> Self {
171 Error::Hash(err.into())
172 }
173}