Skip to main content

gunnar_wire/
refs.rs

1//! One consistent view of the reference namespace, and how a symbolic
2//! reference is followed inside it.
3//!
4//! # Why this exists, and why it is not a resurrected `RefStore`
5//!
6//! `RefStore` was a *trait*: `read`, `iter`, `resolve`, `transaction`, `head`,
7//! `set_head`, implemented per backend. It is deleted, and re-declaring it
8//! anywhere in gunnar is the duplication the 2026-08-10 demolition removes —
9//! `GitOps::refs` / `update_ref` / `put_refs` already say all of it.
10//!
11//! This is not that. It is a **value**: the `Vec<RefRow>` that
12//! [`GitOps::refs`](gunnar_store::git_storage_trait::GitOps::refs) hands back,
13//! with the two questions the wire asks of it. Nothing here can be implemented
14//! by a backend, nothing here is a seam, and no engine can behave differently
15//! through it.
16//!
17//! # It also fixes something the trait made easy to get wrong
18//!
19//! `RefStore::iter` followed by `RefStore::resolve` per symbolic row was N+1
20//! round-trips into the store **and N+1 snapshots**: a ref could move between
21//! the listing and the resolution, so an advertisement could name an oid for a
22//! reference that had already been deleted. One `refs()` call is one snapshot,
23//! and every name in an advertisement is resolved against the same one.
24//!
25//! # `HEAD` is not in here
26//!
27//! `locked_on_target.md`'s `HEAD`-is-not-a-row question, settled: `refs()` walks `refs/` and
28//! **deliberately excludes the pseudo-refs**, which is the contract every
29//! backend honours. `HEAD` has its own accessor pair on `GitServe`
30//! (`head`/`set_head`) and [`RefSnapshot::resolve`] will therefore answer
31//! `None` for it. That is the correct answer here and the reason
32//! `UploadPack::head_ref` asks the store rather than this snapshot.
33//!
34//! # It is `pub`, and it is the only symref walk in the tree
35//!
36//! **2026-08-10, `locked_on_target.md`'s checklist, diff (B): one implementation.**
37//! `gunnar-server` carried a second copy of this walk — free functions over
38//! `&[RefRow]` with the same [`MAX_SYMREF_DEPTH`] cap, written for the control
39//! plane because it could not reach this one. That file is deleted and its
40//! four call sites come here.
41//!
42//! **The two had drifted, and the copy was the wrong one.** `MAX_SYMREF_DEPTH`
43//! is documented as *"how many symbolic **hops** a resolver will follow"*.
44//! [`RefSnapshot::resolve_row`] spends its loop iterations on hops, having
45//! taken the first row before the loop, so it follows the documented five.
46//! gunnar-server's copy spent its first iteration on the initial lookup and
47//! followed **four**, refusing a five-hop chain the advertisement resolves. One
48//! walk, one cap, and now they cannot disagree.
49//!
50//! [`MAX_SYMREF_DEPTH`]: gunnar_store::refname::MAX_SYMREF_DEPTH
51
52use gix_hash::ObjectId;
53use gunnar_store::git_storage_trait::{GitServe, RefRow};
54use gunnar_store::refname::MAX_SYMREF_DEPTH;
55
56use crate::graph::{backend, oid as decode_oid};
57use crate::{Error, Result};
58
59/// A raw oid from the contract, as a `gix_hash::ObjectId`.
60///
61/// A wrong-length id is an error and never a truncation: the contract carries
62/// oids as `Vec<u8>` and a store that answered with the wrong hash's width has
63/// said something no caller must paper over.
64pub fn oid(raw: &[u8]) -> Result<ObjectId> {
65    decode_oid(raw)
66}
67
68/// The reference namespace as of one call to `refs()`.
69pub struct RefSnapshot {
70    rows: Vec<RefRow>,
71}
72
73impl RefSnapshot {
74    /// Take the snapshot.
75    pub fn read(git: &dyn GitServe) -> Result<Self> {
76        Ok(RefSnapshot {
77            rows: git.refs().map_err(backend)?,
78        })
79    }
80
81    /// Every row, in the order the store gave them.
82    pub fn rows(&self) -> &[RefRow] {
83        &self.rows
84    }
85
86    /// The rows whose name starts with `prefix`; all of them for `None`.
87    pub fn with_prefix<'s>(
88        &'s self,
89        prefix: Option<&'s str>,
90    ) -> impl Iterator<Item = &'s RefRow> + 's {
91        self.rows
92            .iter()
93            .filter(move |row| prefix.is_none_or(|p| row.name.starts_with(p)))
94    }
95
96    /// The row called `name`, if there is one.
97    pub fn get(&self, name: &str) -> Option<&RefRow> {
98        self.rows.iter().find(|row| row.name == name)
99    }
100
101    /// What `name` resolves to, following symbolic references.
102    ///
103    /// `None` for a name this snapshot does not hold, and for a symbolic
104    /// reference pointing at one — an unborn branch is the ordinary case of
105    /// the second, not an error.
106    pub fn resolve(&self, name: &str) -> Result<Option<ObjectId>> {
107        match self.get(name) {
108            Some(row) => self.resolve_row(row),
109            None => Ok(None),
110        }
111    }
112
113    /// **What `HEAD` points at**, asked through the accessor pair and resolved
114    /// against this snapshot.
115    ///
116    /// `HEAD` is not a row, so this is the only honest way to ask, and
117    /// it is two questions rather than one: the pair answers what `HEAD` *is*,
118    /// and the target of a symbolic answer resolves against the **same**
119    /// snapshot, so a control-plane answer cannot name an oid for a reference
120    /// that had already moved.
121    ///
122    /// A repository whose `HEAD` names a branch that does not exist answers
123    /// `None`, and that is the state
124    /// `ServerStores::adopt_default_branch_if_unborn` exists to repair: stock
125    /// `git clone` against it writes an empty worktree and exits **0**.
126    pub fn head(&self, git: &dyn GitServe) -> Result<Option<ObjectId>> {
127        let Some(row) = git.head().map_err(backend)? else {
128            return Ok(None);
129        };
130        match &row.oid {
131            Some(raw) => decode_oid(raw).map(Some),
132            None => match &row.symref_target {
133                Some(target) => self.resolve(target),
134                None => Ok(None),
135            },
136        }
137    }
138
139    /// [`RefSnapshot::resolve`] for a row already in hand.
140    ///
141    /// A depth cap rather than a `seen` set, matching
142    /// [`gunnar_store::refname::MAX_SYMREF_DEPTH`]: a cycle and a chain fifty
143    /// deep are the same refusal, and the cap is a number an operator can be
144    /// told. **A cycle must not hang the advertisement**, which is the whole
145    /// reason this is not a `while let`.
146    pub fn resolve_row(&self, row: &RefRow) -> Result<Option<ObjectId>> {
147        let mut current = row;
148        for _ in 0..MAX_SYMREF_DEPTH {
149            if let Some(bytes) = &current.oid {
150                return Ok(Some(decode_oid(bytes)?));
151            }
152            let Some(target) = &current.symref_target else {
153                // Neither an oid nor a symref target: the row says the
154                // reference exists and points at nothing anybody can name.
155                return Ok(None);
156            };
157            let Some(next) = self.get(target) else {
158                // An unborn branch: `refs/heads/main` does not exist yet and
159                // something points at it. Not an error on any backend.
160                return Ok(None);
161            };
162            current = next;
163        }
164        Err(Error::protocol(format!(
165            "reference {:?} is symbolic more than {MAX_SYMREF_DEPTH} levels deep, \
166             which is either a cycle or a chain no client would follow either",
167            row.name
168        )))
169    }
170
171    /// The peeled target a row already carries, when it has one.
172    ///
173    /// `RefRow::peeled` is *"for an annotated tag, the commit it peels to"* and
174    /// a backend that keeps a peel column answers it for free. `None` means
175    /// "this backend does not say", **never** "this tag peels to nothing", so
176    /// the caller falls back to peeling the object itself rather than
177    /// advertising an unpeeled tag.
178    pub fn peeled(row: &RefRow) -> Result<Option<ObjectId>> {
179        match &row.peeled {
180            Some(bytes) => Ok(Some(decode_oid(bytes)?)),
181            None => Ok(None),
182        }
183    }
184}