gunnar-store 1.5.0

The gunnar server's storage seam: RepoStore (which repository), SecretStore (what has no git equivalent), and the one conformance suite both GitOps backends run
Documentation
//! 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 `Exn<Message>` 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.
impl From<gix_error::Exn<gix_error::Message>> for Error {
    fn from(err: gix_error::Exn<gix_error::Message>) -> Self {
        Error::Hash(err.into())
    }
}