Skip to main content

grit_lib/
connectivity.rs

1//! Reachability checks for push / receive-pack connectivity verification.
2//!
3//! Approximates Git's `check_connected` / `rev-list --objects` walk for the cases
4//! exercised by the harness: full object closure from a tip, and "new objects only"
5//! when existing ref tips are treated as roots.
6
7use std::collections::{HashSet, VecDeque};
8
9use std::collections::HashMap;
10
11use crate::error::{Error, Result};
12use crate::objects::{parse_commit, parse_tag, parse_tree, Object, ObjectId, ObjectKind};
13use crate::refs;
14use crate::repo::Repository;
15
16/// Returns every commit reachable from all refs under `refs/` (not `HEAD` alone).
17///
18/// Git's connectivity check uses `--all` (refs), not `HEAD` resolution: a symbolic `HEAD`
19/// that points at `refs/heads/main` must not be treated as an extra root, or pushes that
20/// only advance `main` would be misclassified as already connected.
21fn commit_closure_from_all_refs(repo: &Repository) -> Result<HashSet<ObjectId>> {
22    let mut seeds = Vec::new();
23
24    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
25        if let Ok(entries) = refs::list_refs(&repo.git_dir, prefix) {
26            for (_, oid) in entries {
27                seeds.push(oid);
28            }
29        }
30    }
31
32    let mut seen = HashSet::new();
33    let mut queue: VecDeque<ObjectId> = VecDeque::new();
34
35    for oid in seeds {
36        let obj = match repo.odb.read(&oid) {
37            Ok(o) => o,
38            Err(_) => continue,
39        };
40        match obj.kind {
41            ObjectKind::Commit => {
42                if seen.insert(oid) {
43                    queue.push_back(oid);
44                }
45            }
46            ObjectKind::Tag => {
47                if let Some(target) = peel_tag_to_object(repo, &obj.data)? {
48                    if let Ok(tobj) = repo.odb.read(&target) {
49                        if tobj.kind == ObjectKind::Commit && seen.insert(target) {
50                            queue.push_back(target);
51                        }
52                    }
53                }
54            }
55            _ => {}
56        }
57    }
58
59    while let Some(c) = queue.pop_front() {
60        let obj = repo.odb.read(&c)?;
61        if obj.kind != ObjectKind::Commit {
62            continue;
63        }
64        let commit = parse_commit(&obj.data)?;
65        for p in commit.parents {
66            if seen.insert(p) {
67                queue.push_back(p);
68            }
69        }
70    }
71
72    Ok(seen)
73}
74
75fn peel_tag_to_object(repo: &Repository, tag_data: &[u8]) -> Result<Option<ObjectId>> {
76    let tag = parse_tag(tag_data)?;
77    let mut oid = tag.object;
78    loop {
79        let obj = repo.odb.read(&oid)?;
80        match obj.kind {
81            ObjectKind::Tag => {
82                let inner = parse_tag(&obj.data)?;
83                oid = inner.object;
84            }
85            _ => return Ok(Some(oid)),
86        }
87    }
88}
89
90fn tree_entry_is_tree(mode: u32) -> bool {
91    mode == 0o040000
92}
93
94fn tree_entry_is_blob(mode: u32) -> bool {
95    matches!(mode, 0o100644 | 0o100755 | 0o120000)
96}
97
98/// Walk commits starting from `roots`, optionally stopping parent traversal when a commit is in
99/// `parent_stop` (still enqueueing the edge from child: parent is treated as pre-existing).
100///
101/// Returns `Ok(false)` if any required object is missing from the ODB.
102fn walk_commit_graph_for_push(
103    repo: &Repository,
104    roots: &[ObjectId],
105    parent_stop: Option<&HashSet<ObjectId>>,
106) -> Result<bool> {
107    let mut seen_commits = HashSet::new();
108    let mut seen_trees = HashSet::new();
109    let mut seen_blobs = HashSet::new();
110    let mut commit_q: VecDeque<ObjectId> = VecDeque::new();
111    let mut tree_q: VecDeque<ObjectId> = VecDeque::new();
112
113    for &root in roots {
114        let tip_obj = match repo.odb.read(&root) {
115            Err(_) => return Ok(false),
116            Ok(o) => o,
117        };
118        if tip_obj.kind != ObjectKind::Commit {
119            return Err(Error::CorruptObject(format!(
120                "object {root} is not a commit"
121            )));
122        }
123        if seen_commits.insert(root) {
124            commit_q.push_back(root);
125        }
126    }
127
128    while let Some(c) = commit_q.pop_front() {
129        let obj = match repo.odb.read(&c) {
130            Err(_) => return Ok(false),
131            Ok(o) => o,
132        };
133        if obj.kind != ObjectKind::Commit {
134            return Err(Error::CorruptObject(format!("object {c} is not a commit")));
135        }
136        let commit = parse_commit(&obj.data)?;
137        for p in &commit.parents {
138            if parent_stop.is_some_and(|s| s.contains(p)) {
139                continue;
140            }
141            if seen_commits.insert(*p) {
142                commit_q.push_back(*p);
143            }
144        }
145        if seen_trees.insert(commit.tree) {
146            tree_q.push_back(commit.tree);
147        }
148    }
149
150    while let Some(t) = tree_q.pop_front() {
151        let obj = match repo.odb.read(&t) {
152            Err(_) => return Ok(false),
153            Ok(o) => o,
154        };
155        if obj.kind != ObjectKind::Tree {
156            return Err(Error::CorruptObject(format!("object {t} is not a tree")));
157        }
158        for entry in parse_tree(&obj.data)? {
159            if entry.mode == 0o160000 {
160                // Git submodule link: the pointed-to commit lives in another object store.
161            } else if tree_entry_is_tree(entry.mode) {
162                if seen_trees.insert(entry.oid) {
163                    tree_q.push_back(entry.oid);
164                }
165            } else if tree_entry_is_blob(entry.mode) {
166                if !seen_blobs.insert(entry.oid) {
167                    continue;
168                }
169                if repo.odb.read(&entry.oid).is_err() {
170                    return Ok(false);
171                }
172            }
173        }
174    }
175
176    Ok(true)
177}
178
179/// Verifies that every object reachable from `tip` exists in `repo.odb`.
180///
181/// # Errors
182///
183/// Returns [`Error::CorruptObject`] when `tip` is not a commit or tree data is invalid.
184pub fn push_tip_objects_exist(repo: &Repository, tip: ObjectId) -> Result<bool> {
185    walk_commit_graph_for_push(repo, &[tip], None)
186}
187
188/// Like [`push_tip_objects_exist`], but does not require parent commits listed in
189/// `parent_exceptions` to exist in the ODB (alternate `.have` semantics).
190pub fn push_tip_objects_exist_with_parent_exceptions(
191    repo: &Repository,
192    tip: ObjectId,
193    parent_exceptions: &HashSet<ObjectId>,
194) -> Result<bool> {
195    walk_commit_graph_for_push(repo, &[tip], Some(parent_exceptions))
196}
197
198fn read_object_for_push(
199    repo: &Repository,
200    pack_objects: Option<&HashMap<ObjectId, Object>>,
201    oid: &ObjectId,
202) -> Result<Object> {
203    if let Some(m) = pack_objects {
204        if let Some(o) = m.get(oid) {
205            return Ok(o.clone());
206        }
207    }
208    repo.odb.read(oid)
209}
210
211/// Objects reachable from `tip` that are not already reachable from existing refs (plus
212/// `extra_root_commits`) must exist in the ODB (or in `pack_objects` when verifying before
213/// unpacking a push pack).
214pub fn push_tip_connected_to_refs(
215    repo: &Repository,
216    tip: ObjectId,
217    extra_root_commits: &HashSet<ObjectId>,
218    pack_objects: Option<&HashMap<ObjectId, Object>>,
219) -> Result<bool> {
220    let mut existing = commit_closure_from_all_refs(repo)?;
221    existing.extend(extra_root_commits.iter().copied());
222
223    if existing.contains(&tip) {
224        return Ok(true);
225    }
226
227    let mut seen_commits = HashSet::new();
228    let mut seen_trees = HashSet::new();
229    let mut seen_blobs = HashSet::new();
230    let mut commit_q: VecDeque<ObjectId> = VecDeque::new();
231    let mut tree_q: VecDeque<ObjectId> = VecDeque::new();
232
233    seen_commits.insert(tip);
234    commit_q.push_back(tip);
235
236    while let Some(c) = commit_q.pop_front() {
237        let obj = match read_object_for_push(repo, pack_objects, &c) {
238            Err(_) => return Ok(false),
239            Ok(o) => o,
240        };
241        if obj.kind != ObjectKind::Commit {
242            return Err(Error::CorruptObject(format!("object {c} is not a commit")));
243        }
244        let commit = parse_commit(&obj.data)?;
245        for p in &commit.parents {
246            if existing.contains(p) {
247                continue;
248            }
249            if seen_commits.insert(*p) {
250                commit_q.push_back(*p);
251            }
252        }
253        if seen_trees.insert(commit.tree) {
254            tree_q.push_back(commit.tree);
255        }
256    }
257
258    while let Some(t) = tree_q.pop_front() {
259        let obj = match read_object_for_push(repo, pack_objects, &t) {
260            Err(_) => return Ok(false),
261            Ok(o) => o,
262        };
263        if obj.kind != ObjectKind::Tree {
264            return Err(Error::CorruptObject(format!("object {t} is not a tree")));
265        }
266        for entry in parse_tree(&obj.data)? {
267            if entry.mode == 0o160000 {
268                // Git submodule link: the pointed-to commit lives in another object store.
269            } else if tree_entry_is_tree(entry.mode) {
270                if seen_trees.insert(entry.oid) {
271                    tree_q.push_back(entry.oid);
272                }
273            } else if tree_entry_is_blob(entry.mode) {
274                if !seen_blobs.insert(entry.oid) {
275                    continue;
276                }
277                if read_object_for_push(repo, pack_objects, &entry.oid).is_err() {
278                    return Ok(false);
279                }
280            }
281        }
282    }
283
284    Ok(true)
285}
286
287/// If [`push_tip_connected_to_refs`] would fail, returns a missing object OID and a "context"
288/// commit OID for Git-compatible `Could not read` / `Failed to traverse parents` stderr.
289///
290/// `Ok(None)` means connected (same predicate as [`push_tip_connected_to_refs`] returning `Ok(true)`).
291pub fn diagnose_push_connectivity_failure(
292    repo: &Repository,
293    tip: ObjectId,
294    extra_root_commits: &HashSet<ObjectId>,
295    pack_objects: Option<&HashMap<ObjectId, Object>>,
296) -> Result<Option<(ObjectId, ObjectId)>> {
297    let mut existing = commit_closure_from_all_refs(repo)?;
298    existing.extend(extra_root_commits.iter().copied());
299
300    if existing.contains(&tip) {
301        return Ok(None);
302    }
303
304    let mut seen_commits = HashSet::new();
305    let mut seen_trees = HashSet::new();
306    let mut seen_blobs = HashSet::new();
307    let mut commit_q: VecDeque<ObjectId> = VecDeque::new();
308    let mut tree_q: VecDeque<ObjectId> = VecDeque::new();
309
310    seen_commits.insert(tip);
311    commit_q.push_back(tip);
312
313    while let Some(c) = commit_q.pop_front() {
314        let obj = match read_object_for_push(repo, pack_objects, &c) {
315            Err(_) => return Ok(Some((c, tip))),
316            Ok(o) => o,
317        };
318        if obj.kind != ObjectKind::Commit {
319            return Err(Error::CorruptObject(format!("object {c} is not a commit")));
320        }
321        let commit = parse_commit(&obj.data)?;
322        for p in &commit.parents {
323            if existing.contains(p) {
324                continue;
325            }
326            if read_object_for_push(repo, pack_objects, p).is_err() {
327                return Ok(Some((*p, c)));
328            }
329            if seen_commits.insert(*p) {
330                commit_q.push_back(*p);
331            }
332        }
333        if seen_trees.insert(commit.tree) {
334            tree_q.push_back(commit.tree);
335        }
336    }
337
338    while let Some(t) = tree_q.pop_front() {
339        let obj = match read_object_for_push(repo, pack_objects, &t) {
340            Err(_) => return Ok(Some((t, tip))),
341            Ok(o) => o,
342        };
343        if obj.kind != ObjectKind::Tree {
344            return Err(Error::CorruptObject(format!("object {t} is not a tree")));
345        }
346        for entry in parse_tree(&obj.data)? {
347            if entry.mode == 0o160000 {
348                continue;
349            } else if tree_entry_is_tree(entry.mode) {
350                if seen_trees.insert(entry.oid) {
351                    tree_q.push_back(entry.oid);
352                }
353            } else if tree_entry_is_blob(entry.mode) {
354                if !seen_blobs.insert(entry.oid) {
355                    continue;
356                }
357                if read_object_for_push(repo, pack_objects, &entry.oid).is_err() {
358                    return Ok(Some((entry.oid, tip)));
359                }
360            }
361        }
362    }
363
364    Ok(None)
365}