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 ¤t {
Some(t) => observed_from_gix(t),
None => Observed::Nothing,
};
match (expected, ¤t) {
(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,
}
}