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) = ¤t.oid {
150 return Ok(Some(decode_oid(bytes)?));
151 }
152 let Some(target) = ¤t.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}