Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/storage-git-gix/src/refs.rs

//! The ref namespace, on `gix-ref`'s file store.
//!
//! `refs()`, `update_ref()` and the ref half of `put()` live here.
//!
//! # The one gix defect this module has to work around: `S-023`
//!
//! `gix-ref`'s file store **short-circuits an edit whose new value equals the
//! value the reference already holds**: it rewrites the caller's `expected` to
//! `MustExistAndMatch(current)` and never evaluates the expectation that was
//! written. For `MustNotExist` that turns a create-that-must-fail into a silent
//! success — so "exactly one creator wins", which is the property a receive-pack
//! relies on to arbitrate two racing pushes, is not true.
//!
//! The charred tree found this and fixed it the same way
//! (`parked-gix-2/gunnar-store-gix/src/fs.rs:886`): check the expectation
//! **before** gix sees the batch. [`precheck_cas`] is that check. It is a
//! pre-check, not a lock, so it can only *add* refusals and never accepts
//! anything gix would reject. The race it does not close is a concurrent writer
//! landing between the read and gix's `prepare` with exactly the value being
//! proposed; closing that needs the check inside gix's own transaction, which is
//! upstream work.
//!
//! # No reflog, stated rather than inherited
//!
//! The store is opened with `WriteReflog::Disable`. A git server has no use for
//! a per-ref reflog file tree, and `GitOps` does not expose one. Saying it here
//! rather than reading `core.logallrefupdates` back out of the repository config
//! is part of what keeps `gix-config` out of this crate's build.

use anyhow::{anyhow, Context, Result};
use git_storage_trait::{Observed, RefRejection, RefRow, RefTarget, RefUpdate};
use gix_ref::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix_ref::{FullName, Target};

/// Parse a ref name into gix's validated `FullName`.
fn full_name(name: &str) -> Result<FullName> {
    FullName::try_from(name).with_context(|| format!("invalid ref name {name:?}"))
}

/// Parse a hex object id at the repository's hash width.
fn parse_oid(hex: &str, kind: gix_hash::Kind) -> Result<gix_hash::ObjectId> {
    let id = gix_hash::ObjectId::from_hex(hex.as_bytes())
        .with_context(|| format!("not a hex object id: {hex:?}"))?;
    if id.kind() != kind {
        return Err(anyhow!(
            "object id {hex} is {:?} but this repository is {kind:?}",
            id.kind()
        ));
    }
    Ok(id)
}

/// One [`RefUpdate`] as a gix edit.
///
/// `target: None` and `symref_target: None` is a **delete**; that is the shape
/// `RefUpdate::delete` builds and the only way the contract expresses one.
pub fn to_edit(u: &RefUpdate, kind: gix_hash::Kind) -> Result<RefEdit> {
    let name = full_name(&u.name)?;
    let change = match (&u.target, &u.symref_target) {
        (_, Some(points_to)) => Change::Update {
            log: no_log(),
            expected: PreviousValue::Any,
            new: Target::Symbolic(full_name(points_to)?),
        },
        (Some(hex), None) => Change::Update {
            log: no_log(),
            expected: PreviousValue::Any,
            new: Target::Object(parse_oid(hex, kind)?),
        },
        (None, None) => Change::Delete {
            expected: PreviousValue::Any,
            log: RefLog::AndReference,
        },
    };
    Ok(RefEdit {
        change,
        name,
        // `false`: a push that names `refs/heads/main` means that reference, not
        // whatever it might symbolically point at. Dereferencing here would let
        // a client move a ref it did not name.
        deref: false,
    })
}

fn no_log() -> LogChange {
    LogChange {
        mode: RefLog::AndReference,
        force_create_reflog: false,
        message: Default::default(),
    }
}

/// What gix says was there, **including** the case where it cannot be decoded.
///
/// Lifted from `parked-gix-2/gunnar-store-gix/src/fs.rs:701`, whose two
/// call sites in [`map_prepare_error`] used to write
/// `from_gix_target(actual).ok()` — turning a reference that existed and could
/// not be read into `None`, *reported to the pushing client as "nothing was
/// there"*. The contract's [`Observed`] is a three-state precisely so that
/// collapse is unspellable.
///
/// In *this* crate the undecodable case is narrower than it was in
/// `gunnar-store`, because [`RefTarget::Symbolic`] is a plain `String` and
/// admits every pseudo-ref: what survives is a gix `FullName` that is not valid
/// UTF-8, which gix permits and a `String` cannot hold.
pub fn observed_from_gix(target: &Target) -> Observed {
    match target {
        Target::Object(id) => Observed::Value(RefTarget::Object(id.as_slice().to_vec())),
        Target::Symbolic(name) => {
            let raw: &[u8] = name.as_bstr();
            match std::str::from_utf8(raw) {
                Ok(s) => Observed::Value(RefTarget::Symbolic(s.to_owned())),
                Err(err) => Observed::Unreadable(format!(
                    "a symbolic target gix accepted and this contract cannot name: {err}"
                )),
            }
        }
    }
}

/// The caller's *claim*, as an [`Observed`].
///
/// `None` for the two expectations the contract's three-state cannot name:
/// `Any` (which never fails, so it never needs naming) and the bare `MustExist`
/// (*"some value, I do not say which"*). Neither is reachable through this
/// crate — [`to_edit`] builds only `Any`, and `update_ref`/`put_refs_cas` build
/// only `MustNotExist` and `MustExistAndMatch` — so a caller who somehow
/// produces one gets an untyped error rather than a fabricated `Observed`. That
/// is the same posture the quarantine's `Error::backend(err)` fallback took.
fn expected_observed(p: &PreviousValue) -> Option<Observed> {
    match p {
        PreviousValue::MustNotExist => Some(Observed::Nothing),
        PreviousValue::MustExistAndMatch(t) | PreviousValue::ExistingMustMatch(t) => {
            Some(observed_from_gix(t))
        }
        PreviousValue::Any | PreviousValue::MustExist => None,
    }
}

/// A typed compare-and-swap rejection, as an `anyhow::Error`.
///
/// The contract keeps `anyhow::Result` and carries the type inside it;
/// [`git_storage_trait::RefRejection::of`] is the only sanctioned way to ask.
fn cas_rejection(name: &FullName, expected: Option<Observed>, actual: Observed) -> anyhow::Error {
    let name = name.as_bstr().to_string();
    match expected {
        Some(expected) => anyhow::Error::new(RefRejection::Cas {
            name,
            expected,
            actual,
        }),
        // See [`expected_observed`]: unreachable through this crate's three
        // edit builders, and inventing an `Observed` for it would be worse than
        // saying so.
        None => anyhow!(
            "ref {name} compare-and-swap failed: it is {actual}, and the expectation the caller \
             wrote is one this contract's `Observed` cannot name"
        ),
    }
}

/// `S-023`: evaluate `expected` ourselves, before gix can short-circuit it.
///
/// `read` answers what the reference currently holds. Returns `Err` on the first
/// expectation that does not hold, naming the reference.
///
/// # Why the rejection is typed *here* and not only in [`map_prepare_error`]
///
/// Because of `S-023` itself. This pre-check runs **before** gix sees the batch,
/// so for the create-that-must-fail — the exact case gix short-circuits into a
/// silent success — gix's own `prepare` never fails and `map_prepare_error` is
/// never reached. If only that mapper were typed, `is_cas_failure()` would be
/// `false` for the one rejection receive-pack most needs to name. Both paths
/// raise [`RefRejection::Cas`], and they agree on its three fields.
pub fn precheck_cas(
    edits: &[RefEdit],
    read: impl Fn(&FullName) -> Result<Option<Target>>,
) -> Result<()> {
    for edit in edits {
        let expected = match &edit.change {
            Change::Update { expected, .. } | Change::Delete { expected, .. } => expected,
        };
        let current = read(&edit.name)?;
        // What is actually there, in contract terms. `Nothing` here is the
        // reference genuinely being absent, which is the one case where it is
        // not a guess.
        let actual = || match &current {
            Some(t) => observed_from_gix(t),
            None => Observed::Nothing,
        };
        match (expected, &current) {
            (PreviousValue::Any, _) => {}
            (PreviousValue::MustExist, Some(_)) => {}
            (PreviousValue::MustExist, None) => {
                return Err(cas_rejection(
                    &edit.name,
                    expected_observed(expected),
                    actual(),
                ))
            }
            (PreviousValue::MustNotExist, None) => {}
            (PreviousValue::MustNotExist, Some(_)) => {
                return Err(cas_rejection(
                    &edit.name,
                    expected_observed(expected),
                    actual(),
                ))
            }
            (PreviousValue::MustExistAndMatch(want), Some(have)) if want == have => {}
            (PreviousValue::MustExistAndMatch(_), _) => {
                return Err(cas_rejection(
                    &edit.name,
                    expected_observed(expected),
                    actual(),
                ))
            }
            (PreviousValue::ExistingMustMatch(_), None) => {}
            (PreviousValue::ExistingMustMatch(want), Some(have)) if want == have => {}
            (PreviousValue::ExistingMustMatch(_), _) => {
                return Err(cas_rejection(
                    &edit.name,
                    expected_observed(expected),
                    actual(),
                ))
            }
        }
    }
    Ok(())
}

/// gix's transaction-preparation failure, as the contract's typed rejection.
///
/// A port of `parked-gix-2/gunnar-store-gix/src/fs.rs:794 map_prepare_error`,
/// retargeted from the deleted `gunnar_store::Error` at
/// [`git_storage_trait::RefRejection`]. The four-row table is unchanged; what
/// changed (2026-10-02, gix-ref 0.68 / gix 0.88) is how gix reports each row.
/// `prepare` no longer returns a typed enum but a `gix_error::Error` (an `Exn`
/// before gix-ref 0.69 / gix 0.90), and the rows
/// are recognised the way gix-ref documents them:
///
/// | gix-ref 0.68 reports | was (gix-ref 0.66) | maps to |
/// |---|---|---|
/// | a [`MustNotExist`] in the exception | `MustNotExist { full_name, actual }` | `Cas { name, expected, actual }` |
/// | class `NotFound` ("must exist"), name in metadata `reference` | `MustExist` / `DeleteReferenceMustExist` | `Cas { .., actual: Observed::Nothing }` |
/// | a [`ReferenceOutOfDate`] in the exception | `ReferenceOutOfDate { full_name, actual }` | `Cas { name, expected, actual }` |
/// | class `Retryable` (gix-lock's "already locked"), name in metadata `reference` | `LockAcquire { full_name }` | **`Locked { name }`** |
///
/// Anything else is a genuine backend fault and stays an opaque `anyhow` error,
/// which is what the quarantine's `Error::backend(err)` fallback did.
///
/// # `Locked` is typed on purpose
///
/// The quarantine's comment, verbatim, because it is the argument: *"A lock this
/// writer could not take. **TYPED, not a `Backend`**: it is transient and the one
/// control-document writer retries it. Left as an opaque backend error it read to
/// a caller as a broken disk."* This arm is the one that can raise it at all —
/// both lock modes are `Fail::Immediately`, so contention surfaces instead of
/// stalling a connection.
///
/// [`MustNotExist`]: gix_ref::file::transaction::prepare::MustNotExist
/// [`ReferenceOutOfDate`]: gix_ref::file::transaction::prepare::ReferenceOutOfDate
pub fn map_prepare_error(err: gix_error::Error, edits: &[RefEdit]) -> anyhow::Error {
    use gix_ref::file::transaction::prepare::{MustNotExist, ReferenceOutOfDate};

    // The caller's claim for `full_name`, recovered from the batch that was
    // submitted. gix's error carries what it *found*; only the edit carries what
    // was *asked for*.
    let claim = |full_name: &[u8]| -> Option<(String, Observed)> {
        let name = std::str::from_utf8(full_name).ok()?.to_owned();
        let edit = edits.iter().find(|e| {
            let have: &[u8] = e.name.as_bstr();
            have == full_name
        })?;
        let expected = match &edit.change {
            Change::Update { expected, .. } | Change::Delete { expected, .. } => expected,
        };
        expected_observed(expected).map(|o| (name, o))
    };
    // gix-ref names the failed edit in the `reference` metadata key (the
    // REQUESTED name, as bytes) — the only place the name lives for the rows
    // that carry no typed error of their own.
    let reference: Option<Vec<u8>> = err.metadata().find_map(|values| {
        match values.get("reference")? {
            gix_error::MetadataValue::Bytes(name) => Some(name.to_vec()),
            gix_error::MetadataValue::String(name) => Some(name.as_bytes().to_vec()),
            _ => None,
        }
    });

    let cas = |name: &[u8], actual: Observed| {
        claim(name).map(|(name, expected)| {
            anyhow::Error::new(RefRejection::Cas {
                name,
                expected,
                actual,
            })
        })
    };
    let typed = if let Some(e) = err.downcast_any_ref::<MustNotExist>() {
        cas(&e.full_name, observed_from_gix(&e.actual))
    } else if let Some(e) = err.downcast_any_ref::<ReferenceOutOfDate>() {
        cas(&e.full_name, observed_from_gix(&e.actual))
    } else if err.is_not_found() {
        // Not a swallow: gix reports no observed value for this row, so
        // `Nothing` is the honest answer rather than a dropped one.
        reference.as_deref().and_then(|name| cas(name, Observed::Nothing))
    } else if err.is_retryable() {
        // A lock this writer could not take. TYPED, not a `Backend`: it is
        // transient and the one control-document writer retries it. Left as an
        // opaque backend error it read to a caller as a broken disk.
        reference
            .as_deref()
            .and_then(|name| std::str::from_utf8(name).ok())
            .map(|name| {
                anyhow::Error::new(RefRejection::Locked {
                    name: name.to_owned(),
                })
            })
    } else {
        None
    };
    typed.unwrap_or_else(|| anyhow::Error::new(err).context("ref transaction refused"))
}

/// `refs/heads/x/y` and `refs/heads/x` cannot both be files.
///
/// gix refuses the pair, but with a lock error whose text is about a file and a
/// directory. Running this first means the caller gets one recognisable message
/// for the class instead of having to read gix's.
pub fn precheck_name_conflict(edits: &[RefEdit], existing: &[String]) -> Result<()> {
    let new_names: Vec<&str> = edits
        .iter()
        .filter(|e| matches!(e.change, Change::Update { .. }))
        .filter_map(|e| std::str::from_utf8(e.name.as_bstr()).ok())
        .collect();
    let deleted: Vec<&str> = edits
        .iter()
        .filter(|e| matches!(e.change, Change::Delete { .. }))
        .filter_map(|e| std::str::from_utf8(e.name.as_bstr()).ok())
        .collect();

    let mut candidates: Vec<&str> = new_names.clone();
    for e in existing {
        if !deleted.contains(&e.as_str()) && !candidates.contains(&e.as_str()) {
            candidates.push(e.as_str());
        }
    }

    for n in &new_names {
        for other in &candidates {
            if n == other {
                continue;
            }
            if other.starts_with(&format!("{n}/")) || n.starts_with(&format!("{other}/")) {
                return Err(anyhow!(
                    "ref name conflict: {n} and {other} cannot both exist"
                ));
            }
        }
    }
    Ok(())
}

/// `Target` → the contract's `(oid, symref_target)` pair.
pub fn split_target(t: &Target) -> (Option<Vec<u8>>, Option<String>) {
    match t {
        Target::Object(id) => (Some(id.as_slice().to_vec()), None),
        Target::Symbolic(name) => (None, Some(name.as_bstr().to_string())),
    }
}

/// Build a `RefRow`, peeling an annotated tag if `peel` can.
///
/// `peeled` is `Some` only for a tag object that resolves to something else —
/// that is what the contract's field means and what a client's ref
/// advertisement carries as `name^{}`.
pub fn row(
    name: String,
    target: &Target,
    peel: impl FnOnce(&gix_hash::oid) -> Option<gix_hash::ObjectId>,
) -> RefRow {
    let (oid, symref_target) = split_target(target);
    let peeled = match target {
        Target::Object(id) => peel(id).map(|p| p.as_slice().to_vec()),
        Target::Symbolic(_) => None,
    };
    RefRow {
        name,
        oid,
        peeled,
        symref_target,
    }
}