Skip to main content

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}