crates/gunnar-store/src/error.rs
//! The one error type this crate reports through.
//!
//! # What left with `ObjectStore` and `RefStore`, and what it cost
//!
//! This type used to carry `RefCas { name, expected, actual }` and the
//! `Observed` three-state beside it, and `Error::is_cas_failure()` was how
//! `gunnar-wire`'s receive-pack told *"another push beat you to it"* apart from
//! *"the disk is broken"*.
//!
//! [`GitOps::update_ref`](git_storage_trait::GitOps::update_ref) returns
//! `anyhow::Result<TxId>`. A compare-and-swap rejection through the frozen
//! contract is therefore **untyped**, and a caller can no longer distinguish a
//! lost race from a backend fault without reading a message. That is a real
//! narrowing and it is recorded here as a fact rather than papered over: the
//! contract is frozen by default, changing it is a
//! two-implementation decision, and a backend whose honest answer is narrower
//! than the caller wants records that rather than editing the trait.
//!
//! The variants below are the ones this crate can still *raise*: name rules
//! ([`crate::refname`]), the Arrow secret log's two, and the three plumbing
//! variants everything needs.
/// The crate-wide result type.
pub type Result<T, E = Error> = std::result::Result<T, E>;
/// Everything `gunnar-store` itself can go wrong with.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// A reference name is not a legal git reference name.
///
/// Raised by [`crate::refname::validate`]. A name with a `..` component in
/// it is how a path sandbox gets walked out of, so this is a security
/// boundary as much as a correctness one.
#[error("invalid reference name {name:?}: {reason}")]
InvalidRefName {
/// The offending name, lossily decoded for display.
name: String,
/// Why it was rejected.
reason: &'static str,
},
/// **Two reference names cannot both exist: one is a directory prefix of
/// the other.**
///
/// `refs/heads/x` and `refs/heads/x/y` are a file and a directory of the
/// same name in a loose-ref backend, and git refuses the pair on *every*
/// backend so a repository stays convertible between them. Nothing raced and
/// retrying will never help, which is why it is a variant of its own.
///
/// Measured, git 2.53.0, `git push` into a bare repository: the per-ref
/// answer is `! [remote rejected] (refname conflict)`, and when both names
/// arrive in one push the server refuses the batch with `cannot process
/// 'refs/heads/x' and 'refs/heads/x/y' at the same time`.
#[error("refname conflict: {name} cannot exist while {conflicts_with} does")]
RefNameConflict {
/// The reference the transaction tried to write.
name: String,
/// The reference already present, or written by the same transaction,
/// that is a directory prefix of `name` or has `name` as one.
conflicts_with: String,
},
/// An object's stored bytes could not be decoded.
#[error("object {oid} is corrupt: {reason}")]
CorruptObject {
/// The object that failed to decode.
oid: gix_hash::ObjectId,
/// Why decoding failed.
reason: String,
},
/// An object that something else points at is not in the store.
///
/// Raised while walking a reference's closure: a repository that cannot
/// serve one of its own refs. Not a fault of the caller's request, which is
/// why it is not an [`Error::Backend`].
#[error("object {id} is referenced{} but is not in the store", match referrer {
Some(from) => format!(" by {from}"),
None => String::new(),
})]
MissingObject {
/// The object that could not be found.
id: gix_hash::ObjectId,
/// What pointed at it, when that is known.
referrer: Option<gix_hash::ObjectId>,
},
/// The store was opened with one hash kind but is holding another.
///
/// A sha1 repo and a sha256 repo are different repos; silently mixing them
/// would produce oids that no client can verify.
#[error("hash kind mismatch: store holds {stored}, was opened as {requested}")]
HashKindMismatch {
/// What the store already contains.
stored: gix_hash::Kind,
/// What the caller asked for.
requested: gix_hash::Kind,
},
/// Another writer already holds this store's exclusive lock.
///
/// Compare-and-swap over an append-only log is only sound with one writer:
/// two appenders at the end of an Arrow IPC stream interleave and destroy
/// the frame boundaries, and the format has no reservation protocol. A
/// second gunnar therefore **refuses to start** (the Arrow-IPC ref log).
///
/// Since the liveness check this means the holder is **alive**, or that it could not
/// be *shown* to be gone: no `/proc`, an unreadable owner line, or a lock
/// written by a gunnar that recorded no start time. A lock whose owner is
/// provably gone is reclaimed by
/// `ArrowLogStore::open` itself and never
/// produces this error. `ArrowLogStore::force_unlock`
/// is for the undecidable remainder.
#[error(
"the store is locked by another writer ({}); its owner is still running, or could \
not be shown to be gone. Clear the lock only if no gunnar is running against it",
path.display()
)]
StoreLocked {
/// The lock file that is already present.
path: std::path::PathBuf,
},
/// Hashing failed. Since gix-hash 0.27 (gix 0.88, 2026-10-02) hashing
/// reports a `gix_error` exception instead of `hasher::Error`; it is kept
/// here as a `gix_error::Error`.
#[error("could not compute an object hash")]
Hash(#[source] gix_error::Error),
/// I/O failed.
#[error(transparent)]
Io(#[from] std::io::Error),
/// A backend-specific failure that has no gunnar-level meaning.
#[error(transparent)]
Backend(#[from] anyhow::Error),
}
impl Error {
/// `true` if two names were refused because they cannot coexist.
///
/// Kept as a predicate rather than left to a `matches!` at each call site
/// because receive-pack reports it to the client as a *refname conflict*,
/// which is a different answer from a non-fast-forward and from an internal
/// error, and a retry will never change it.
pub fn is_name_conflict(&self) -> bool {
matches!(self, Error::RefNameConflict { .. })
}
/// Build a [`Error::Backend`] from anything printable.
pub fn backend(msg: impl std::fmt::Display) -> Self {
Error::Backend(anyhow::anyhow!("{msg}"))
}
}
// `impl From<arrow_schema::ArrowError> for Error` used to be here, generated by
// a `from_via_anyhow!` macro, so that the Arrow log's `?` converted implicitly.
// It went with the Arrow log on 2026-08-10: it was the last `arrow-*` edge in
// this crate, and this crate is what both `GitOps` backends dev-depend on from
// two other repositories to run the conformance suite. One `From` impl is not
// worth making znippy resolve `arrow-schema`. `gunnar_core::arrow_log::arrow_err`
// is the single replacement, and it is one function rather than a `map_err`
// closure per call site.
/// Every gix call that reports a `gix_error::Error` inside this crate is a hash
/// computation (`compute_hash`, `Hasher::try_finalize`), so `?` lands in
/// [`Error::Hash`] exactly as `#[from] gix_hash::hasher::Error` did before gix 0.88
/// (gix 0.88 reported `Exn<Message>` there; gix 0.90 reports the `Error` itself).
impl From<gix_error::Error> for Error {
fn from(err: gix_error::Error) -> Self {
Error::Hash(err)
}
}