radicle/git/
canonical.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use nonempty::NonEmpty;
5use raw::Repository;
6use thiserror::Error;
7
8use crate::prelude::Did;
9use crate::prelude::Project;
10use crate::storage::ReadRepository;
11
12use super::raw;
13use super::{lit, Oid, Qualified};
14
15/// A collection of [`Did`]s and their [`Oid`]s that is the tip for a given
16/// reference for that [`Did`].
17///
18/// The general construction of `Canonical` is by using the
19/// [`Canonical::reference`] constructor. For the default branch of a
20/// [`Project`], use [`Canonical::default_branch`].
21///
22/// `Canonical` can then be used for performing calculations about the
23/// canonicity of the reference, most importantly the [`Canonical::quorum`].
24pub struct Canonical {
25    tips: BTreeMap<Did, Oid>,
26}
27
28/// Error that can occur when calculation the [`Canonical::quorum`].
29#[derive(Debug, Error)]
30pub enum QuorumError {
31    /// Could not determine a quorum [`Oid`], due to diverging tips.
32    #[error("could not determine canonical reference tip, {0}")]
33    Diverging(Diverging),
34    /// Could not determine a base candidate from the given set of delegates.
35    #[error("could not determine canonical reference tip, {0}")]
36    NoCandidates(NoCandidates),
37    /// An error occurred from [`git2`].
38    #[error(transparent)]
39    Git(#[from] git2::Error),
40}
41
42/// No candidates were found for the [`Canonical::quorum`] calculation.
43///
44/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on
45/// the threshold and delegates in the calculation.
46#[derive(Debug)]
47pub struct NoCandidates {
48    threshold: usize,
49}
50
51impl fmt::Display for NoCandidates {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        let NoCandidates { threshold } = self;
54        write!(
55            f,
56            "no commit found with at least {threshold} vote(s) (threshold not met)"
57        )
58    }
59}
60
61/// Diverging commits were found during the [`Canonical::quorum`] calculation.
62///
63/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on
64/// the threshold, base commit, and the two diverging commits, in the
65/// calculation.
66#[derive(Debug)]
67pub struct Diverging {
68    threshold: usize,
69    base: Oid,
70    longest: Oid,
71    head: Oid,
72}
73
74impl fmt::Display for Diverging {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        let Diverging {
77            threshold,
78            base,
79            longest,
80            head,
81        } = self;
82        write!(f, "found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")
83    }
84}
85
86impl Canonical {
87    /// Construct the set of canonical tips of the `Project::default_branch` for
88    /// the given `delegates`.
89    pub fn default_branch<S>(
90        repo: &S,
91        project: &Project,
92        delegates: &NonEmpty<Did>,
93    ) -> Result<Self, raw::Error>
94    where
95        S: ReadRepository,
96    {
97        Self::reference(
98            repo,
99            delegates,
100            &lit::refs_heads(project.default_branch()).into(),
101        )
102    }
103
104    /// Construct the set of canonical tips given for the given `delegates` and
105    /// the reference `name`.
106    pub fn reference<S>(
107        repo: &S,
108        delegates: &NonEmpty<Did>,
109        name: &Qualified,
110    ) -> Result<Self, raw::Error>
111    where
112        S: ReadRepository,
113    {
114        let mut tips = BTreeMap::new();
115        for delegate in delegates.iter() {
116            match repo.reference_oid(delegate, name) {
117                Ok(tip) => {
118                    tips.insert(*delegate, tip);
119                }
120                Err(e) if super::ext::is_not_found_err(&e) => {
121                    log::warn!(
122                        target: "radicle",
123                        "Missing `refs/namespaces/{}/{name}` while calculating the canonical reference",
124                        delegate.as_key()
125                    );
126                }
127                Err(e) => return Err(e),
128            }
129        }
130        Ok(Canonical { tips })
131    }
132
133    /// Return the set of [`Did`]s and their [`Oid`] tip.
134    pub fn tips(&self) -> impl Iterator<Item = (&Did, &Oid)> {
135        self.tips.iter()
136    }
137}
138
139/// Check that a given `target` converges with any of the provided `tips`.
140///
141/// It converges if the `target` is either equal to, ahead of, or behind any of
142/// the tips.
143pub fn converges<'a>(
144    tips: impl Iterator<Item = &'a Oid>,
145    target: Oid,
146    repo: &Repository,
147) -> Result<bool, raw::Error> {
148    for tip in tips {
149        match repo.graph_ahead_behind(*target, **tip)? {
150            (0, 0) => return Ok(true),
151            (ahead, behind) if ahead > 0 && behind == 0 => return Ok(true),
152            (ahead, behind) if behind > 0 && ahead == 0 => return Ok(true),
153            (_, _) => {}
154        }
155    }
156    Ok(false)
157}
158
159impl Canonical {
160    /// In some cases, we allow the vote to be modified. For example, when the
161    /// `did` is pushing a new commit, we may want to see if the new commit will
162    /// reach a quorum.
163    pub fn modify_vote(&mut self, did: Did, new: Oid) {
164        self.tips.insert(did, new);
165    }
166
167    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
168    /// and the threshold. This can be described as the latest commit that is
169    /// included in at least `threshold` histories. In case there are multiple tips
170    /// passing the threshold, and they are divergent, an error is returned.
171    ///
172    /// Also returns an error if `heads` is empty or `threshold` cannot be
173    /// satisified with the number of heads given.
174    pub fn quorum(&self, threshold: usize, repo: &raw::Repository) -> Result<Oid, QuorumError> {
175        let mut candidates = BTreeMap::<_, usize>::new();
176
177        // Build a list of candidate commits and count how many "votes" each of them has.
178        // Commits get a point for each direct vote, as well as for being part of the ancestry
179        // of a commit given to this function. Only commits given to the function are considered.
180        for (i, head) in self.tips.values().enumerate() {
181            // Add a direct vote for this head.
182            *candidates.entry(*head).or_default() += 1;
183
184            // Compare this head to all other heads ahead of it in the list.
185            for other in self.tips.values().skip(i + 1) {
186                // N.b. if heads are equal then skip it, otherwise it will end up as
187                // a double vote.
188                if *head == *other {
189                    continue;
190                }
191                let base = Oid::from(repo.merge_base(**head, **other)?);
192
193                if base == *other || base == *head {
194                    *candidates.entry(base).or_default() += 1;
195                }
196            }
197        }
198        // Keep commits which pass the threshold.
199        candidates.retain(|_, votes| *votes >= threshold);
200
201        let (mut longest, _) = candidates
202            .pop_first()
203            .ok_or_else(|| QuorumError::NoCandidates(NoCandidates { threshold }))?;
204
205        // Now that all scores are calculated, figure out what is the longest branch
206        // that passes the threshold. In case of divergence, return an error.
207        for head in candidates.keys() {
208            let base = repo.merge_base(**head, *longest)?;
209
210            if base == *longest {
211                // `head` is a successor of `longest`. Update `longest`.
212                //
213                //   o head
214                //   |
215                //   o longest (base)
216                //   |
217                //
218                longest = *head;
219            } else if base == **head || *head == longest {
220                // `head` is an ancestor of `longest`, or equal to it. Do nothing.
221                //
222                //   o longest             o longest, head (base)
223                //   |                     |
224                //   o head (base)   OR    o
225                //   |                     |
226                //
227            } else {
228                // The merge base between `head` and `longest` (`base`)
229                // is neither `head` nor `longest`. Therefore, the branches have
230                // diverged.
231                //
232                //    longest   head
233                //           \ /
234                //            o (base)
235                //            |
236                //
237                return Err(QuorumError::Diverging(Diverging {
238                    threshold,
239                    base: base.into(),
240                    longest,
241                    head: *head,
242                }));
243            }
244        }
245        Ok((*longest).into())
246    }
247}
248
249#[cfg(test)]
250#[allow(clippy::unwrap_used)]
251mod tests {
252    use crypto::test::signer::MockSigner;
253    use radicle_crypto::Signer;
254
255    use super::*;
256    use crate::assert_matches;
257    use crate::git;
258    use crate::test::fixtures;
259
260    /// Test helper to construct a Canonical and get the quorum
261    fn quorum(
262        heads: &[git::raw::Oid],
263        threshold: usize,
264        repo: &git::raw::Repository,
265    ) -> Result<Oid, QuorumError> {
266        let tips = heads
267            .iter()
268            .enumerate()
269            .map(|(i, head)| {
270                let signer = MockSigner::from_seed([(i + 1) as u8; 32]);
271                let did = Did::from(signer.public_key());
272                (did, (*head).into())
273            })
274            .collect();
275        Canonical { tips }.quorum(threshold, repo)
276    }
277
278    #[test]
279    fn test_quorum_properties() {
280        let tmp = tempfile::tempdir().unwrap();
281        let (repo, c0) = fixtures::repository(tmp.path());
282        let c0: git::Oid = c0.into();
283        let a1 = fixtures::commit("A1", &[*c0], &repo);
284        let a2 = fixtures::commit("A2", &[*a1], &repo);
285        let d1 = fixtures::commit("D1", &[*c0], &repo);
286        let c1 = fixtures::commit("C1", &[*c0], &repo);
287        let c2 = fixtures::commit("C2", &[*c1], &repo);
288        let b2 = fixtures::commit("B2", &[*c1], &repo);
289        let a1 = fixtures::commit("A1", &[*c0], &repo);
290        let m1 = fixtures::commit("M1", &[*c2, *b2], &repo);
291        let m2 = fixtures::commit("M2", &[*a1, *b2], &repo);
292        let mut rng = fastrand::Rng::new();
293        let choices = [*c0, *c1, *c2, *b2, *a1, *a2, *d1, *m1, *m2];
294
295        for _ in 0..100 {
296            let count = rng.usize(1..=choices.len());
297            let threshold = rng.usize(1..=count);
298            let mut heads = Vec::new();
299
300            for _ in 0..count {
301                let ix = rng.usize(0..choices.len());
302                heads.push(choices[ix]);
303            }
304            rng.shuffle(&mut heads);
305
306            if let Ok(canonical) = quorum(&heads, threshold, &repo) {
307                assert!(heads.contains(&canonical));
308            }
309        }
310    }
311
312    #[test]
313    fn test_quorum() {
314        let tmp = tempfile::tempdir().unwrap();
315        let (repo, c0) = fixtures::repository(tmp.path());
316        let c0: git::Oid = c0.into();
317        let c1 = fixtures::commit("C1", &[*c0], &repo);
318        let c2 = fixtures::commit("C2", &[*c1], &repo);
319        let c3 = fixtures::commit("C3", &[*c1], &repo);
320        let b2 = fixtures::commit("B2", &[*c1], &repo);
321        let a1 = fixtures::commit("A1", &[*c0], &repo);
322        let m1 = fixtures::commit("M1", &[*c2, *b2], &repo);
323        let m2 = fixtures::commit("M2", &[*a1, *b2], &repo);
324
325        eprintln!("C0: {c0}");
326        eprintln!("C1: {c1}");
327        eprintln!("C2: {c2}");
328        eprintln!("C3: {c3}");
329        eprintln!("B2: {b2}");
330        eprintln!("A1: {a1}");
331        eprintln!("M1: {m1}");
332        eprintln!("M2: {m2}");
333
334        assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0);
335        assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
336        assert_eq!(quorum(&[*c2], 1, &repo).unwrap(), c2);
337        assert_eq!(quorum(&[*c0], 0, &repo).unwrap(), c0);
338        assert_matches!(quorum(&[], 0, &repo), Err(QuorumError::NoCandidates(_)));
339        assert_matches!(quorum(&[*c0], 2, &repo), Err(QuorumError::NoCandidates(_)));
340
341        //  C1
342        //  |
343        // C0
344        assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
345
346        //   C2
347        //   |
348        //  C1
349        //  |
350        // C0
351        assert_eq!(quorum(&[*c1, *c2], 1, &repo).unwrap(), c2);
352        assert_eq!(quorum(&[*c1, *c2], 2, &repo).unwrap(), c1);
353        assert_eq!(quorum(&[*c0, *c1, *c2], 3, &repo).unwrap(), c0);
354        assert_eq!(quorum(&[*c1, *c1, *c2], 2, &repo).unwrap(), c1);
355        assert_eq!(quorum(&[*c1, *c1, *c2], 1, &repo).unwrap(), c2);
356        assert_eq!(quorum(&[*c2, *c2, *c1], 1, &repo).unwrap(), c2);
357
358        // B2 C2
359        //   \|
360        //   C1
361        //   |
362        //  C0
363        assert_matches!(
364            quorum(&[*c1, *c2, *b2], 1, &repo),
365            Err(QuorumError::Diverging(_))
366        );
367        assert_matches!(
368            quorum(&[*c2, *b2], 1, &repo),
369            Err(QuorumError::Diverging(_))
370        );
371        assert_matches!(
372            quorum(&[*b2, *c2], 1, &repo),
373            Err(QuorumError::Diverging(_))
374        );
375        assert_matches!(
376            quorum(&[*c2, *b2], 2, &repo),
377            Err(QuorumError::NoCandidates(_))
378        );
379        assert_matches!(
380            quorum(&[*b2, *c2], 2, &repo),
381            Err(QuorumError::NoCandidates(_))
382        );
383        assert_eq!(quorum(&[*c1, *c2, *b2], 2, &repo).unwrap(), c1);
384        assert_eq!(quorum(&[*c1, *c2, *b2], 3, &repo).unwrap(), c1);
385        assert_eq!(quorum(&[*b2, *b2, *c2], 2, &repo).unwrap(), b2);
386        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
387        assert_matches!(
388            quorum(&[*b2, *b2, *c2, *c2], 2, &repo),
389            Err(QuorumError::Diverging(_))
390        );
391
392        // B2 C2 C3
393        //  \ | /
394        //    C1
395        //    |
396        //    C0
397        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
398        assert_matches!(
399            quorum(&[*b2, *c2, *c2], 3, &repo),
400            Err(QuorumError::NoCandidates(_))
401        );
402        assert_matches!(
403            quorum(&[*b2, *c2, *b2, *c2], 3, &repo),
404            Err(QuorumError::NoCandidates(_))
405        );
406        assert_matches!(
407            quorum(&[*c3, *b2, *c2, *b2, *c2, *c3], 3, &repo),
408            Err(QuorumError::NoCandidates(_))
409        );
410
411        //  B2 C2
412        //    \|
413        // A1 C1
414        //   \|
415        //   C0
416        assert_matches!(
417            quorum(&[*c2, *b2, *a1], 1, &repo),
418            Err(QuorumError::Diverging(_))
419        );
420        assert_matches!(
421            quorum(&[*c2, *b2, *a1], 2, &repo),
422            Err(QuorumError::NoCandidates(_))
423        );
424        assert_matches!(
425            quorum(&[*c2, *b2, *a1], 3, &repo),
426            Err(QuorumError::NoCandidates(_))
427        );
428        assert_matches!(
429            quorum(&[*c1, *c2, *b2, *a1], 4, &repo),
430            Err(QuorumError::NoCandidates(_))
431        );
432        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 2, &repo).unwrap(), c1,);
433        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 3, &repo).unwrap(), c1,);
434        assert_eq!(quorum(&[*c0, *c2, *b2, *a1], 3, &repo).unwrap(), c0);
435        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0,);
436        assert_matches!(
437            quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo),
438            Err(QuorumError::Diverging(_))
439        );
440        assert_matches!(
441            quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo),
442            Err(QuorumError::Diverging(_))
443        );
444        assert_matches!(
445            quorum(&[*a1, *a1, *c2], 1, &repo),
446            Err(QuorumError::Diverging(_))
447        );
448        assert_matches!(
449            quorum(&[*b2, *b2, *c2, *c2], 1, &repo),
450            Err(QuorumError::Diverging(_))
451        );
452        assert_matches!(
453            quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo),
454            Err(QuorumError::Diverging(_))
455        );
456
457        //    M2  M1
458        //    /\  /\
459        //    \ B2 C2
460        //     \  \|
461        //     A1 C1
462        //       \|
463        //       C0
464        assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
465        assert_matches!(
466            quorum(&[*m1, *m2], 1, &repo),
467            Err(QuorumError::Diverging(_))
468        );
469        assert_matches!(
470            quorum(&[*m2, *m1], 1, &repo),
471            Err(QuorumError::Diverging(_))
472        );
473        assert_matches!(
474            quorum(&[*m1, *m2], 2, &repo),
475            Err(QuorumError::NoCandidates(_))
476        );
477        assert_matches!(
478            quorum(&[*m1, *m2, *c2], 1, &repo),
479            Err(QuorumError::Diverging(_))
480        );
481        assert_matches!(
482            quorum(&[*m1, *a1], 1, &repo),
483            Err(QuorumError::Diverging(_))
484        );
485        assert_matches!(
486            quorum(&[*m1, *a1], 2, &repo),
487            Err(QuorumError::NoCandidates(_))
488        );
489        assert_eq!(quorum(&[*m1, *m2, *b2, *c1], 4, &repo).unwrap(), c1);
490        assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
491        assert_eq!(quorum(&[*m1, *m1, *c2], 2, &repo).unwrap(), m1);
492        assert_eq!(quorum(&[*m2, *m2, *b2], 2, &repo).unwrap(), m2);
493        assert_eq!(quorum(&[*m2, *m2, *a1], 2, &repo).unwrap(), m2);
494        assert_eq!(quorum(&[*m1, *m1, *b2, *b2], 2, &repo).unwrap(), m1);
495        assert_eq!(quorum(&[*m1, *m1, *c2, *c2], 2, &repo).unwrap(), m1);
496        assert_eq!(quorum(&[*m1, *b2, *c1, *c0], 3, &repo).unwrap(), c1);
497        assert_eq!(quorum(&[*m1, *b2, *c1, *c0], 4, &repo).unwrap(), c0);
498    }
499
500    #[test]
501    fn test_quorum_merges() {
502        let tmp = tempfile::tempdir().unwrap();
503        let (repo, c0) = fixtures::repository(tmp.path());
504        let c0: git::Oid = c0.into();
505        let c1 = fixtures::commit("C1", &[*c0], &repo);
506        let c2 = fixtures::commit("C2", &[*c0], &repo);
507        let c3 = fixtures::commit("C3", &[*c0], &repo);
508
509        let m1 = fixtures::commit("M1", &[*c1, *c2], &repo);
510        let m2 = fixtures::commit("M2", &[*c2, *c3], &repo);
511
512        eprintln!("C0: {c0}");
513        eprintln!("C1: {c1}");
514        eprintln!("C2: {c2}");
515        eprintln!("C3: {c3}");
516        eprintln!("M1: {m1}");
517        eprintln!("M2: {m2}");
518
519        //    M2  M1
520        //    /\  /\
521        //   C1 C2 C3
522        //     \| /
523        //      C0
524        assert_matches!(
525            quorum(&[*m1, *m2], 1, &repo),
526            Err(QuorumError::Diverging(_))
527        );
528        assert_matches!(
529            quorum(&[*m1, *m2], 2, &repo),
530            Err(QuorumError::NoCandidates(_))
531        );
532
533        let m3 = fixtures::commit("M3", &[*c2, *c1], &repo);
534
535        //   M3/M2 M1
536        //    /\  /\
537        //   C1 C2 C3
538        //     \| /
539        //      C0
540        assert_matches!(
541            quorum(&[*m1, *m3], 1, &repo),
542            Err(QuorumError::Diverging(_))
543        );
544        assert_matches!(
545            quorum(&[*m1, *m3], 2, &repo),
546            Err(QuorumError::NoCandidates(_))
547        );
548        assert_matches!(
549            quorum(&[*m3, *m1], 1, &repo),
550            Err(QuorumError::Diverging(_))
551        );
552        assert_matches!(
553            quorum(&[*m3, *m1], 2, &repo),
554            Err(QuorumError::NoCandidates(_))
555        );
556        assert_matches!(
557            quorum(&[*m3, *m2], 1, &repo),
558            Err(QuorumError::Diverging(_))
559        );
560        assert_matches!(
561            quorum(&[*m3, *m2], 2, &repo),
562            Err(QuorumError::NoCandidates(_))
563        );
564    }
565}