Skip to main content

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}