gunnar_wire/shallow.rs
1//! Shallow and deepening: `--depth`, `--shallow-since`, `--shallow-exclude`.
2//!
3//! All five wire arguments (`shallow`, `deepen`, `deepen-relative`,
4//! `deepen-since`, `deepen-not`) are gated on the single advertised `shallow`
5//! feature, so advertising it is a promise to accept all five. They are
6//! therefore implemented rather than rejected. See [`crate::caps`] for why
7//! that is not optional.
8//!
9//! Everything reduces to one traversal that assigns each commit a depth and
10//! cuts where the depth, the date, or an excluded ref says to. The cut set is
11//! the **boundary**: commits that go in the pack but whose parents do not.
12
13use std::collections::{HashMap, HashSet, VecDeque};
14
15use crate::refs::RefSnapshot;
16use gix_hash::ObjectId;
17
18use crate::graph::{Graph, Missing};
19use crate::{Error, Result};
20
21/// The shallow-related arguments of one fetch request.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct ShallowRequest {
24 /// Where the client's history is already cut, from `shallow <oid>`.
25 pub client_shallow: Vec<ObjectId>,
26 /// `deepen <n>`.
27 pub depth: Option<usize>,
28 /// `deepen-relative`: count `depth` from the client's existing boundary
29 /// rather than from the wants.
30 pub relative: bool,
31 /// `deepen-since <unix seconds>`.
32 pub since: Option<i64>,
33 /// `deepen-not <ref>`.
34 pub not: Vec<String>,
35}
36
37impl ShallowRequest {
38 /// Does this request say anything about shallowness at all?
39 pub fn is_active(&self) -> bool {
40 !self.client_shallow.is_empty()
41 || self.depth.is_some()
42 || self.since.is_some()
43 || !self.not.is_empty()
44 }
45
46 /// Does it ask for a *change* to the boundary, as opposed to merely
47 /// declaring where the client's is?
48 pub fn is_deepening(&self) -> bool {
49 self.depth.is_some() || self.since.is_some() || !self.not.is_empty()
50 }
51}
52
53/// What the server decided about the boundary.
54#[derive(Debug, Clone, Default)]
55pub struct ShallowOutcome {
56 /// Commits the client declared shallow, whose parents are not traversed
57 /// at all.
58 pub boundary: HashSet<ObjectId>,
59 /// Where the **client's own** history stops, verbatim from its `shallow
60 /// <oid>` lines.
61 ///
62 /// Not the same field as `boundary` and not redundant with it, because the
63 /// two bound different walks. `boundary` bounds the walk that decides what
64 /// to *send*, and a deepening request deliberately walks past the client's
65 /// cut, which is what it asked for. This one bounds the walk over the
66 /// client's `have`s, which may never walk past the cut: a client that said
67 /// `shallow X` holds `X` and none of `X`'s ancestors, so treating a `have
68 /// X` as proof it holds them subtracts the whole history from the pack.
69 pub client_boundary: HashSet<ObjectId>,
70 /// When a deepening was requested, exactly the commits this fetch may
71 /// send. Empty when the request only declared an existing boundary.
72 ///
73 /// Separate from `boundary` because a merge can be cut off from one parent
74 /// and keep the other (`deepen-not` does exactly that), and a boundary
75 /// cannot express it.
76 pub include: HashSet<ObjectId>,
77 /// `shallow <oid>` lines to send: new cut points the client does not know.
78 pub newly_shallow: Vec<ObjectId>,
79 /// `unshallow <oid>` lines to send: commits that used to be cut and are
80 /// now complete because this fetch sends their parents.
81 pub unshallow: Vec<ObjectId>,
82 /// The parents of every commit in `unshallow`: extra tips the pack walk
83 /// must start from.
84 ///
85 /// An `unshallow <oid>` line is a promise that the pack carries that
86 /// commit's parents, and the wants alone cannot deliver it. The client
87 /// offers its shallow tip as a `have` in the very same request, so the tip
88 /// is correctly excluded from the pack, and a walk seeded only with the
89 /// wants stops there and sends nothing, having already promised otherwise.
90 /// Seeding the parents is how `git-upload-pack` closes the same gap (see
91 /// `send_unshallow` in its `upload-pack.c`).
92 pub unshallow_parents: Vec<ObjectId>,
93}
94
95impl ShallowOutcome {
96 /// Is there a `shallow-info` section worth sending?
97 pub fn has_info(&self) -> bool {
98 !self.newly_shallow.is_empty() || !self.unshallow.is_empty()
99 }
100}
101
102/// git's `ref_rev_parse_rules`, which is how `deepen-not` turns the name a
103/// person typed into a reference.
104///
105/// Copied from git's table rather than shortened: the last rule is the one a
106/// mirror needs, and `{}` stands where git writes `%.*s`.
107const DEEPEN_NOT_RULES: &[&str] = &[
108 "{}",
109 "refs/{}",
110 "refs/tags/{}",
111 "refs/heads/{}",
112 "refs/remotes/{}",
113 "refs/remotes/{}/HEAD",
114];
115
116/// Resolve one `deepen-not <name>` the way `git-upload-pack` does.
117///
118/// # The bug this replaces, and it was silent on both sides
119///
120/// This used to be `FullName::new(name)` followed by `refs.resolve`, with
121/// `continue` on either failure. `--shallow-exclude` takes a **revision**, and
122/// git's own `expand_ref` walks `ref_rev_parse_rules`, so `--shallow-exclude=side`
123/// is the ordinary spelling — it is the spelling `Scene::dated`'s own table in
124/// the interop suite is written in. `FullName` requires `HEAD` or `refs/…`, so
125/// the short form parsed as nothing, was discarded, and the deepening ran with
126/// **no exclusion at all**.
127///
128/// MEASURED 2026-08-05, `Scene::dated` (A─B─M─D with a side branch C),
129/// `clone --shallow-exclude=side`, stock git 2.53.0 as the client:
130///
131/// | server | commits | `.git/shallow` |
132/// |---|---|---|
133/// | stock `git-upload-pack` | 2 | 2 cut points |
134/// | gunnar, before | **5** | **empty** |
135///
136/// The client asked for a shallow clone excluding a branch and got the whole
137/// history, not shallow, with no error on either end. That is the worst shape a
138/// refusal can take: it is not a refusal, it is a different answer.
139///
140/// The unresolvable case is the same defect wearing the other face. git dies
141/// with `ambiguous deepen-not: <name>`; gunnar discarded it, so
142/// `--shallow-exclude=typo` silently became "exclude nothing".
143///
144/// # Ambiguity is an error, as it is in git
145///
146/// `expand_ref` counts **every** rule that matches (it only stops early when
147/// `core.warnAmbiguousRefs` is off, which is not the default) and
148/// `upload-pack.c` dies unless exactly one did. So a repository holding both
149/// `refs/heads/x` and `refs/tags/x` refuses `deepen-not x` rather than picking
150/// one, and gunnar refuses it too — silently picking would make the answer
151/// depend on the order of a table.
152fn resolve_deepen_not(refs: &RefSnapshot, name: &str) -> Result<ObjectId> {
153 let mut found: Vec<(String, ObjectId)> = Vec::new();
154 for rule in DEEPEN_NOT_RULES {
155 let candidate = rule.replace("{}", name);
156 // `FullName::new` was the validator here and it is gone; the rules it
157 // enforced live on in `refname::validate`, which is the same rule set
158 // in the place `locked_on_target.md`'s checklist names. The name comes from a
159 // fixed table with one client-supplied component, so an invalid one
160 // means the client's revision was not a legal ref component — skip the
161 // rule, exactly as before, and let the count below decide.
162 if gunnar_store::refname::validate(candidate.as_bytes()).is_err() {
163 continue;
164 }
165 if found.iter().any(|(seen, _)| seen == &candidate) {
166 continue;
167 }
168 if let Some(id) = refs.resolve(&candidate)? {
169 found.push((candidate, id));
170 }
171 }
172 match found.len() {
173 1 => Ok(found[0].1),
174 // Named, and reaching the client: `serve_session` turns this into an
175 // `ERR` pkt-line. A `deepen-not` nobody can resolve is not a detail to
176 // log — it is the difference between the history the client asked for
177 // and the one it would otherwise be given.
178 0 => Err(crate::Error::protocol(format!(
179 "deepen-not {name:?} names no reference in this repository"
180 ))),
181 n => Err(crate::Error::protocol(format!(
182 "ambiguous deepen-not {name:?}: it names {n} references ({})",
183 found
184 .iter()
185 .map(|(full, _)| full.clone())
186 .collect::<Vec<_>>()
187 .join(", ")
188 ))),
189 }
190}
191
192/// Work out the boundary for a request.
193pub(crate) fn compute(
194 graph: &Graph<'_>,
195 refs: &RefSnapshot,
196 want_commits: &[ObjectId],
197 request: &ShallowRequest,
198) -> Result<ShallowOutcome> {
199 let client_shallow: HashSet<ObjectId> = request.client_shallow.iter().copied().collect();
200
201 if !request.is_deepening() {
202 // The client is only telling us where it is already cut. Traversal
203 // stops there so the pack neither contains nor requires the ancestors
204 // it grafted away.
205 return Ok(ShallowOutcome {
206 boundary: client_shallow.clone(),
207 client_boundary: client_shallow,
208 include: HashSet::new(),
209 newly_shallow: Vec::new(),
210 unshallow: Vec::new(),
211 unshallow_parents: Vec::new(),
212 });
213 }
214
215 // `deepen-not <ref>` excludes a ref's whole ancestry.
216 let mut excluded: HashSet<ObjectId> = HashSet::new();
217 for name in &request.not {
218 let tip = resolve_deepen_not(refs, name)?;
219 let closure = graph.commit_closure(
220 &[tip],
221 &HashSet::new(),
222 &HashSet::new(),
223 None,
224 Missing::Skip,
225 )?;
226 excluded.extend(closure.ids);
227 }
228
229 let max_depth = request.depth;
230 let mut depth_of: HashMap<ObjectId, usize> = HashMap::new();
231 let mut queue: VecDeque<ObjectId> = VecDeque::new();
232 let mut boundary: HashSet<ObjectId> = HashSet::new();
233 let mut reached: HashSet<ObjectId> = HashSet::new();
234
235 // With `deepen-relative` the wants start at depth 0 and the counter only
236 // starts once the walk crosses the client's existing boundary; without it
237 // the wants themselves are depth 1, which is what makes `--depth=1` send
238 // exactly the tips. Both are the same rule with a different seed.
239 let seed_depth = if request.relative { 0 } else { 1 };
240 for want in want_commits {
241 if excluded.contains(want) {
242 continue;
243 }
244 // **The date cut applies to the WANT itself, not only to its parents.**
245 //
246 // `deepen-since` is `rev-list --max-age=<since>` in git, and a commit
247 // older than the cutoff is not *listed* — being a tip does not exempt
248 // it. gunnar seeded every want unconditionally and tested only parents'
249 // dates, so a request whose tip predates the cutoff was answered with
250 // that tip: the client asked for commits newer than a date and was
251 // handed one older than it, cut as shallow, with no error at either
252 // end.
253 //
254 // MEASURED 2026-08-05, git 2.53.0, three commits dated
255 // 1700001000/2000/3000 and `--shallow-since=@1700009000`:
256 //
257 // | server | result |
258 // |---|---|
259 // | stock `git-upload-pack` | `fatal: no commits selected for shallow requests` |
260 // | gunnar, before | a 1-commit clone of a commit older than the cutoff |
261 //
262 // The refusal below is the other half: with every want cut away there
263 // is nothing to select, and that is precisely when git dies rather
264 // than serving a smaller answer.
265 if let Some(since) = request.since {
266 match graph.commit(want) {
267 Ok(facts) if facts.committed_at < since => continue,
268 Ok(_) => {}
269 Err(crate::Error::ObjectMissing { .. }) => continue,
270 Err(err) => return Err(err),
271 }
272 }
273 if depth_of.insert(*want, seed_depth).is_none() {
274 queue.push_back(*want);
275 }
276 }
277
278 // git's `deepen_by_rev_list` dies when its rev-list comes back empty, and
279 // says so by name. Serving an empty pack instead would tell the client the
280 // request succeeded and it already has everything, which is a wrong answer
281 // it cannot detect — the silent half of the same defect as the seeding
282 // above. Only reachable when a date or an exclusion removed every want;
283 // `--depth` cannot, because it cuts below the wants and never at them.
284 if queue.is_empty() && !want_commits.is_empty() {
285 return Err(Error::protocol(
286 "no commits selected for shallow requests: deepen-since and deepen-not between them \
287 exclude every requested tip",
288 ));
289 }
290
291 while let Some(id) = queue.pop_front() {
292 reached.insert(id);
293 let depth = depth_of[&id];
294 if max_depth.is_some_and(|max| depth >= max) {
295 boundary.insert(id);
296 continue;
297 }
298 let facts = match graph.commit(&id) {
299 Ok(facts) => facts,
300 Err(crate::Error::ObjectMissing { .. }) => {
301 boundary.insert(id);
302 continue;
303 }
304 Err(err) => return Err(err),
305 };
306 let mut cut_here = false;
307 let child_depth = if request.relative && depth == 0 && !client_shallow.contains(&id) {
308 0
309 } else {
310 depth + 1
311 };
312 for parent in facts.parents {
313 if excluded.contains(&parent) {
314 cut_here = true;
315 continue;
316 }
317 if let Some(since) = request.since {
318 match graph.commit(&parent) {
319 Ok(parent_facts) if parent_facts.committed_at < since => {
320 cut_here = true;
321 continue;
322 }
323 Ok(_) => {}
324 Err(crate::Error::ObjectMissing { .. }) => {
325 cut_here = true;
326 continue;
327 }
328 Err(err) => return Err(err),
329 }
330 }
331 if !graph.contains(&parent)? {
332 cut_here = true;
333 continue;
334 }
335 if let std::collections::hash_map::Entry::Vacant(slot) = depth_of.entry(parent) {
336 slot.insert(child_depth);
337 queue.push_back(parent);
338 }
339 }
340 if cut_here {
341 boundary.insert(id);
342 }
343 }
344
345 // A boundary commit with no parents is not shallow. It is a root, and
346 // telling the client its root is grafted makes every later fetch of that
347 // repository ask for a parent that does not exist.
348 let mut newly_shallow: Vec<ObjectId> = boundary
349 .iter()
350 .filter(|id| !client_shallow.contains(*id))
351 .filter(|id| {
352 graph
353 .commit(id)
354 .map(|facts| !facts.parents.is_empty())
355 .unwrap_or(false)
356 })
357 .copied()
358 .collect();
359 newly_shallow.sort();
360
361 let mut unshallow: Vec<ObjectId> = client_shallow
362 .iter()
363 .filter(|id| reached.contains(*id) && !boundary.contains(*id))
364 .copied()
365 .collect();
366 unshallow.sort();
367
368 // The parents of everything being unshallowed. Bounded by `reached`
369 // because that is what this request decided it may send: a commit is only
370 // in `unshallow` when none of its parents was cut, so this loop finds them
371 // all there, and the filter is a guard rather than a narrowing.
372 let mut unshallow_parents: Vec<ObjectId> = Vec::new();
373 for id in &unshallow {
374 for parent in graph.commit(id)?.parents {
375 if reached.contains(&parent) && !unshallow_parents.contains(&parent) {
376 unshallow_parents.push(parent);
377 }
378 }
379 }
380
381 Ok(ShallowOutcome {
382 // Deliberately empty: `include` already says exactly which commits this
383 // fetch may send, and `boundary` stops *every* parent of the commits
384 // in it. Returning the cut set here as well would drop the surviving
385 // parent of a merge that `deepen-not` only half-cut, the same
386 // conflation `Graph::commit_closure` warns about.
387 boundary: HashSet::new(),
388 client_boundary: client_shallow,
389 include: reached,
390 newly_shallow,
391 unshallow,
392 unshallow_parents,
393 })
394}