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

nordisk/gunnar

crates/gunnar-wire/src/refs.rs

//! One consistent view of the reference namespace, and how a symbolic
//! reference is followed inside it.
//!
//! # Why this exists, and why it is not a resurrected `RefStore`
//!
//! `RefStore` was a *trait*: `read`, `iter`, `resolve`, `transaction`, `head`,
//! `set_head`, implemented per backend. It is deleted, and re-declaring it
//! anywhere in gunnar is the duplication the 2026-08-10 demolition removes —
//! `GitOps::refs` / `update_ref` / `put_refs` already say all of it.
//!
//! This is not that. It is a **value**: the `Vec<RefRow>` that
//! [`GitOps::refs`](gunnar_store::git_storage_trait::GitOps::refs) hands back,
//! with the two questions the wire asks of it. Nothing here can be implemented
//! by a backend, nothing here is a seam, and no engine can behave differently
//! through it.
//!
//! # It also fixes something the trait made easy to get wrong
//!
//! `RefStore::iter` followed by `RefStore::resolve` per symbolic row was N+1
//! round-trips into the store **and N+1 snapshots**: a ref could move between
//! the listing and the resolution, so an advertisement could name an oid for a
//! reference that had already been deleted. One `refs()` call is one snapshot,
//! and every name in an advertisement is resolved against the same one.
//!
//! # `HEAD` is not in here
//!
//! `locked_on_target.md`'s `HEAD`-is-not-a-row question, settled: `refs()` walks `refs/` and
//! **deliberately excludes the pseudo-refs**, which is the contract every
//! backend honours. `HEAD` has its own accessor pair on `GitServe`
//! (`head`/`set_head`) and [`RefSnapshot::resolve`] will therefore answer
//! `None` for it. That is the correct answer here and the reason
//! `UploadPack::head_ref` asks the store rather than this snapshot.
//!
//! # It is `pub`, and it is the only symref walk in the tree
//!
//! **2026-08-10, `locked_on_target.md`'s checklist, diff (B): one implementation.**
//! `gunnar-server` carried a second copy of this walk — free functions over
//! `&[RefRow]` with the same [`MAX_SYMREF_DEPTH`] cap, written for the control
//! plane because it could not reach this one. That file is deleted and its
//! four call sites come here.
//!
//! **The two had drifted, and the copy was the wrong one.** `MAX_SYMREF_DEPTH`
//! is documented as *"how many symbolic **hops** a resolver will follow"*.
//! [`RefSnapshot::resolve_row`] spends its loop iterations on hops, having
//! taken the first row before the loop, so it follows the documented five.
//! gunnar-server's copy spent its first iteration on the initial lookup and
//! followed **four**, refusing a five-hop chain the advertisement resolves. One
//! walk, one cap, and now they cannot disagree.
//!
//! [`MAX_SYMREF_DEPTH`]: gunnar_store::refname::MAX_SYMREF_DEPTH

use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::{GitServe, RefRow};
use gunnar_store::refname::MAX_SYMREF_DEPTH;

use crate::graph::{backend, oid as decode_oid};
use crate::{Error, Result};

/// A raw oid from the contract, as a `gix_hash::ObjectId`.
///
/// A wrong-length id is an error and never a truncation: the contract carries
/// oids as `Vec<u8>` and a store that answered with the wrong hash's width has
/// said something no caller must paper over.
pub fn oid(raw: &[u8]) -> Result<ObjectId> {
    decode_oid(raw)
}

/// The reference namespace as of one call to `refs()`.
pub struct RefSnapshot {
    rows: Vec<RefRow>,
}

impl RefSnapshot {
    /// Take the snapshot.
    pub fn read(git: &dyn GitServe) -> Result<Self> {
        Ok(RefSnapshot {
            rows: git.refs().map_err(backend)?,
        })
    }

    /// Every row, in the order the store gave them.
    pub fn rows(&self) -> &[RefRow] {
        &self.rows
    }

    /// The rows whose name starts with `prefix`; all of them for `None`.
    pub fn with_prefix<'s>(
        &'s self,
        prefix: Option<&'s str>,
    ) -> impl Iterator<Item = &'s RefRow> + 's {
        self.rows
            .iter()
            .filter(move |row| prefix.is_none_or(|p| row.name.starts_with(p)))
    }

    /// The row called `name`, if there is one.
    pub fn get(&self, name: &str) -> Option<&RefRow> {
        self.rows.iter().find(|row| row.name == name)
    }

    /// What `name` resolves to, following symbolic references.
    ///
    /// `None` for a name this snapshot does not hold, and for a symbolic
    /// reference pointing at one — an unborn branch is the ordinary case of
    /// the second, not an error.
    pub fn resolve(&self, name: &str) -> Result<Option<ObjectId>> {
        match self.get(name) {
            Some(row) => self.resolve_row(row),
            None => Ok(None),
        }
    }

    /// **What `HEAD` points at**, asked through the accessor pair and resolved
    /// against this snapshot.
    ///
    /// `HEAD` is not a row, so this is the only honest way to ask, and
    /// it is two questions rather than one: the pair answers what `HEAD` *is*,
    /// and the target of a symbolic answer resolves against the **same**
    /// snapshot, so a control-plane answer cannot name an oid for a reference
    /// that had already moved.
    ///
    /// A repository whose `HEAD` names a branch that does not exist answers
    /// `None`, and that is the state
    /// `ServerStores::adopt_default_branch_if_unborn` exists to repair: stock
    /// `git clone` against it writes an empty worktree and exits **0**.
    pub fn head(&self, git: &dyn GitServe) -> Result<Option<ObjectId>> {
        let Some(row) = git.head().map_err(backend)? else {
            return Ok(None);
        };
        match &row.oid {
            Some(raw) => decode_oid(raw).map(Some),
            None => match &row.symref_target {
                Some(target) => self.resolve(target),
                None => Ok(None),
            },
        }
    }

    /// [`RefSnapshot::resolve`] for a row already in hand.
    ///
    /// A depth cap rather than a `seen` set, matching
    /// [`gunnar_store::refname::MAX_SYMREF_DEPTH`]: a cycle and a chain fifty
    /// deep are the same refusal, and the cap is a number an operator can be
    /// told. **A cycle must not hang the advertisement**, which is the whole
    /// reason this is not a `while let`.
    pub fn resolve_row(&self, row: &RefRow) -> Result<Option<ObjectId>> {
        let mut current = row;
        for _ in 0..MAX_SYMREF_DEPTH {
            if let Some(bytes) = &current.oid {
                return Ok(Some(decode_oid(bytes)?));
            }
            let Some(target) = &current.symref_target else {
                // Neither an oid nor a symref target: the row says the
                // reference exists and points at nothing anybody can name.
                return Ok(None);
            };
            let Some(next) = self.get(target) else {
                // An unborn branch: `refs/heads/main` does not exist yet and
                // something points at it. Not an error on any backend.
                return Ok(None);
            };
            current = next;
        }
        Err(Error::protocol(format!(
            "reference {:?} is symbolic more than {MAX_SYMREF_DEPTH} levels deep, \
             which is either a cycle or a chain no client would follow either",
            row.name
        )))
    }

    /// The peeled target a row already carries, when it has one.
    ///
    /// `RefRow::peeled` is *"for an annotated tag, the commit it peels to"* and
    /// a backend that keeps a peel column answers it for free. `None` means
    /// "this backend does not say", **never** "this tag peels to nothing", so
    /// the caller falls back to peeling the object itself rather than
    /// advertising an unpeeled tag.
    pub fn peeled(row: &RefRow) -> Result<Option<ObjectId>> {
        match &row.peeled {
            Some(bytes) => Ok(Some(decode_oid(bytes)?)),
            None => Ok(None),
        }
    }
}