1use std::collections::BTreeMap;
42use std::path::PathBuf;
43
44use serde::{Deserialize, Serialize};
45use serde_json::json;
46use url::Url;
47
48use crate::boundary::{Surface, TermScope};
49use crate::error::{invalid, Error, Result};
50use crate::event::EventKind;
51use crate::host::{ChangeId, RemoteHost, Sha};
52use crate::providers::Providers;
53use crate::review::{ReadCost, ReadMarker, ReviewRead};
54use crate::stream::Stream;
55use crate::workspace::{object, Ref};
56use crate::{git, home, ids, publish, release, review, status, store};
57
58pub(crate) mod github;
59
60pub(crate) const STANDINGS_BATCH: usize = 50;
64
65pub(crate) const REVIEW_BATCH: usize = 150;
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ChangeStandingsRequest {
77 pub repo: String,
79 pub root: String,
81 pub changes: Vec<Url>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ChangeStanding {
92 pub url: Url,
94 pub body: String,
96 pub base: String,
98 pub head_branch: String,
100 pub head: Sha,
102 pub draft: bool,
104 pub state: ChangeState,
106 pub checks: ChecksVerdict,
108}
109
110#[non_exhaustive]
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "state", rename_all = "kebab-case")]
114pub enum ChangeState {
115 Open,
117 Merged {
119 into: String,
121 commit: Sha,
123 landing: bool,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
130 merged_at: Option<String>,
131 },
132 Closed {
135 base_deleted: bool,
138 },
139}
140
141#[non_exhaustive]
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "verdict", rename_all = "kebab-case")]
146pub enum ChecksVerdict {
147 Pending {
150 waiting: Vec<String>,
152 },
153 Passed,
155 PassedWithSkipped {
158 skipped: Vec<String>,
160 },
161 Failed {
163 failed: Vec<String>,
165 },
166 NoneRequired,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ChangeStandings {
173 pub standings: Vec<ChangeStanding>,
175 pub cost: ReadCost,
177}
178
179pub fn change_standings(
185 providers: &Providers<'_>,
186 request: &ChangeStandingsRequest,
187) -> Result<ChangeStandings> {
188 let repository = Repository::of(providers, &request.repo)?;
189 let root = branch_name(&request.root, "the root")?;
190 let ids = repository.ids(request.changes.iter())?;
191 let read = if ids.is_empty() {
192 ChangeStandings {
193 standings: Vec::new(),
194 cost: ReadCost::default(),
195 }
196 } else {
197 repository.host.change_standings(&root, &ids)?
198 };
199 repository.answered(&ids, &read.standings)?;
200 let landed = repository.take_up(&read.standings, &root);
201 repository.record_read(read.standings.len(), &landed, read.cost);
202 Ok(read)
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct ReviewTarget {
209 pub change: Url,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub since: Option<ReadMarker>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct ReviewStandingsRequest {
221 pub repo: String,
223 pub root: String,
225 pub changes: Vec<ReviewTarget>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct ReviewStandings {
232 pub standings: Vec<ChangeStanding>,
234 pub reads: Vec<ReviewRead>,
236 pub cost: ReadCost,
238}
239
240pub fn review_standings(
248 providers: &Providers<'_>,
249 request: &ReviewStandingsRequest,
250) -> Result<ReviewStandings> {
251 let repository = Repository::of(providers, &request.repo)?;
252 let root = branch_name(&request.root, "the root")?;
253 let ids = repository.ids(request.changes.iter().map(|target| &target.change))?;
254 let targets: Vec<(ChangeId, Option<ReadMarker>)> = ids
255 .iter()
256 .cloned()
257 .zip(request.changes.iter().map(|target| target.since.clone()))
258 .collect();
259 let read = if targets.is_empty() {
260 ReviewStandings {
261 standings: Vec::new(),
262 reads: Vec::new(),
263 cost: ReadCost::default(),
264 }
265 } else {
266 repository.host.review_standings(&root, &targets)?
267 };
268 repository.answered(&ids, &read.standings)?;
269 if read.reads.len() != ids.len() {
270 return Err(invalid(format!(
271 "the host answered {} review read(s) for {} change request(s), so which feedback \
272 belongs to which cannot be read",
273 read.reads.len(),
274 ids.len()
275 )));
276 }
277 let landed = repository.take_up(&read.standings, &root);
278 for review_read in &read.reads {
279 review::record_url_read(&repository.key, review_read);
280 }
281 repository.record_read(read.standings.len(), &landed, read.cost);
282 Ok(read)
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct RetargetRequest {
292 pub repo: String,
294 pub change: Url,
296 pub base: String,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub body: Option<String>,
301 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
303 pub remove_restored_base: bool,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct Retargeted {
309 pub change: Url,
311 pub from: String,
313 pub to: String,
315 pub outcome: RetargetOutcome,
317 pub cost: ReadCost,
319}
320
321#[non_exhaustive]
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "kebab-case")]
325pub enum RetargetOutcome {
326 Retargeted,
328 Unchanged,
330}
331
332pub fn retarget_change(providers: &Providers<'_>, request: &RetargetRequest) -> Result<Retargeted> {
341 let repository = Repository::of(providers, &request.repo)?;
342 let base = branch_name(&request.base, "the base")?;
343 let id = repository.id(&request.change)?;
344 let mut cost = ReadCost::default();
345 let before = repository.one(&id, &base, &mut cost)?;
346 let url = before.url.clone();
347 match &before.state {
348 ChangeState::Open => {}
349 ChangeState::Merged { into, .. } => {
350 return Err(invalid(format!(
351 "{url} has merged into {into:?}, so its base cannot change. Nothing was written"
352 )))
353 }
354 _ => {
355 return Err(invalid(format!(
356 "{url} is closed, and a closed change request's base cannot change: reopen it \
357 first — `onevcs change reopen` restores a deleted base and reopens it. Nothing \
358 was written"
359 )))
360 }
361 }
362 let from = before.base.clone();
363 let moves = from != base;
364 if request.remove_restored_base {
365 if !moves {
366 return Err(invalid(format!(
367 "{url} already targets {base:?}, so no base is left behind for \
368 --remove-restored-base to delete. Nothing was written"
369 )));
370 }
371 if !restored_on(&url, &from)? {
372 return Err(invalid(format!(
373 "{from:?} is not a base `onevcs change reopen` restored on {url}, so \
374 --remove-restored-base will not delete it. Nothing was written"
375 )));
376 }
377 }
378 if !moves && request.body.is_none() {
379 repository.record_change(
380 &url,
381 EventKind::ChangeRetargeted,
382 json!({
383 "change_url": url.to_string(),
384 "from": from,
385 "to": base,
386 "outcome": "unchanged",
387 "body_written": false,
388 }),
389 );
390 return Ok(Retargeted {
391 change: url,
392 to: base,
393 from,
394 outcome: RetargetOutcome::Unchanged,
395 cost,
396 });
397 }
398 let tips = repository.origin_branches()?;
399 if moves && !tips.contains_key(&base) {
400 return Err(invalid(format!(
401 "the origin of {} holds no branch {base:?}, so {url} cannot target it. Nothing was \
402 written",
403 repository.key
404 )));
405 }
406 let mut fields = vec![(Surface::Branch, base.as_str())];
409 if let Some(body) = &request.body {
410 fields.push((Surface::Body, body.as_str()));
411 }
412 crate::boundary::evidence::guard_fields(
413 providers.hosting,
414 &repository.key,
415 &fields,
416 &TermScope::default(),
417 )?;
418 add(
419 &mut cost,
420 repository
421 .host
422 .retarget_change(&id, &base, request.body.as_deref())?,
423 );
424 let after = repository.one(&id, &base, &mut cost)?;
425 if after.draft != before.draft {
426 return Err(invalid(format!(
427 "the host moved {url} onto {base:?} and answered it {} where it was {} — this \
428 crate never changes a change request's draft state, so the host's answer is \
429 refused",
430 draft_word(after.draft),
431 draft_word(before.draft)
432 )));
433 }
434 if after.base != base {
435 return Err(invalid(format!(
436 "the host accepted moving {url} onto {base:?} and reports its base as {:?}",
437 after.base
438 )));
439 }
440 if request.remove_restored_base {
441 let expected = tips
442 .get(&from)
443 .map(|tip| tip.as_str().to_owned())
444 .ok_or_else(|| {
445 invalid(format!(
446 "{url} moved onto {base:?}, and the restored base {from:?} is already gone \
447 from the origin, so there was nothing to delete"
448 ))
449 })?;
450 let deleted = git::delete_remote_branch(&repository.publication, ORIGIN, &from, &expected)?;
451 if !deleted.accepted() {
452 return Err(Error::PushRejected {
453 reason: format!(
454 "{url} moved onto {base:?}, and the origin declined to delete the restored \
455 base {from:?}: {}",
456 deleted.output().lines().next_back().unwrap_or("").trim()
457 ),
458 });
459 }
460 }
461 let outcome = if moves {
462 RetargetOutcome::Retargeted
463 } else {
464 RetargetOutcome::Unchanged
465 };
466 repository.record_change(
467 &url,
468 EventKind::ChangeRetargeted,
469 json!({
470 "change_url": url.to_string(),
471 "from": from,
472 "to": base,
473 "outcome": outcome_word(outcome),
474 "body_written": request.body.is_some(),
475 }),
476 );
477 Ok(Retargeted {
478 change: url,
479 from,
480 to: base,
481 outcome,
482 cost,
483 })
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct ReopenRequest {
492 pub repo: String,
494 pub change: Url,
496 pub restore_base_at: Sha,
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502pub struct Reopened {
503 pub change: Url,
505 pub restored_base: String,
507 pub cost: ReadCost,
509}
510
511pub fn reopen_change(providers: &Providers<'_>, request: &ReopenRequest) -> Result<Reopened> {
520 let repository = Repository::of(providers, &request.repo)?;
521 let id = repository.id(&request.change)?;
522 let at = git::ObjectId::parse(&request.restore_base_at.0).ok_or_else(|| {
523 invalid(format!(
524 "{:?} is not a commit id; pass the full id of the commit to restore the base at",
525 request.restore_base_at.0
526 ))
527 })?;
528 let mut cost = ReadCost::default();
529 let before = repository.one(&id, "", &mut cost)?;
530 let url = before.url.clone();
531 if !matches!(before.state, ChangeState::Closed { .. }) {
532 return Err(invalid(format!(
533 "{url} is {}, not closed, so there is nothing to reopen. Nothing was written",
534 match before.state {
535 ChangeState::Open => "open",
536 _ => "merged",
537 }
538 )));
539 }
540 let base = branch_name(&before.base, "the change request's base")?;
541 if repository.origin_branches()?.contains_key(&base) {
542 return Err(invalid(format!(
543 "the origin of {} still holds {base:?}, the base of {url}, so there is no deleted \
544 base to restore. Nothing was written",
545 repository.key
546 )));
547 }
548 let commit = Sha(at.as_str().to_owned());
549 if !git::has_commit(repository.publication.as_path(), &commit)
550 && !git::fetch_commit(&repository.publication, ORIGIN, at.as_str())?
551 {
552 return Err(invalid(format!(
553 "{} is not a commit {} holds or can fetch from its origin, so {base:?} cannot be \
554 restored at it. Nothing was written",
555 at.as_str(),
556 repository.publication.display()
557 )));
558 }
559 let compared = format!(
560 "{ORIGIN}/{}",
561 git::default_branch(&repository.publication, ORIGIN)?
562 );
563 crate::boundary::evidence::guard_publication(
564 providers.hosting,
565 &repository.key,
566 &crate::boundary::screen::Outgoing {
567 repo: &repository.publication,
568 base: Some(compared.as_str()),
569 tip: at.as_str(),
570 branch: Some(base.as_str()),
571 title: None,
572 body: None,
573 },
574 &TermScope::default(),
575 )?;
576 let pushed = git::restore_remote_branch(&repository.publication, ORIGIN, &base, at.as_str())?;
577 if !pushed.accepted() {
578 return Err(Error::PushRejected {
579 reason: format!(
580 "the origin of {} declined restoring {base:?} at {}: {}. Nothing was reopened",
581 repository.key,
582 at.as_str(),
583 pushed.output().lines().next_back().unwrap_or("").trim()
584 ),
585 });
586 }
587 add(&mut cost, repository.host.reopen_change(&id)?);
588 let after = repository.one(&id, "", &mut cost)?;
589 if after.state != ChangeState::Open {
590 return Err(invalid(format!(
591 "the host accepted reopening {url} and still reports it {}; {base:?} stays restored \
592 at {}",
593 match after.state {
594 ChangeState::Closed { .. } => "closed",
595 _ => "merged",
596 },
597 at.as_str()
598 )));
599 }
600 if after.draft != before.draft {
601 return Err(invalid(format!(
602 "the host reopened {url} {} where it was {} — this crate never changes a change \
603 request's draft state, so the host's answer is refused",
604 draft_word(after.draft),
605 draft_word(before.draft)
606 )));
607 }
608 repository.record_change(
609 &url,
610 EventKind::ChangeReopened,
611 json!({
612 "change_url": url.to_string(),
613 "restored_base": base,
614 "at": at.as_str(),
615 }),
616 );
617 Ok(Reopened {
618 change: url,
619 restored_base: base,
620 cost,
621 })
622}
623
624const ORIGIN: &str = "origin";
626
627fn add(total: &mut ReadCost, more: ReadCost) {
628 total.graphql_points = total.graphql_points.saturating_add(more.graphql_points);
629 total.rest_requests = total.rest_requests.saturating_add(more.rest_requests);
630}
631
632fn draft_word(draft: bool) -> &'static str {
633 if draft {
634 "a draft"
635 } else {
636 "ready for review"
637 }
638}
639
640fn outcome_word(outcome: RetargetOutcome) -> &'static str {
641 match outcome {
642 RetargetOutcome::Retargeted => "retargeted",
643 RetargetOutcome::Unchanged => "unchanged",
644 }
645}
646
647fn branch_name(value: &str, what: &str) -> Result<String> {
649 Ref::try_from(value.to_owned())
650 .map(String::from)
651 .map_err(|reason| invalid(format!("{what} is {reason}")))
652}
653
654struct Repository {
656 host: Box<dyn RemoteHost>,
657 key: String,
659 publication: PathBuf,
662 registry: crate::registry::Registry,
663}
664
665impl Repository {
666 fn of(providers: &Providers<'_>, repo: &str) -> Result<Self> {
667 let registry = store::load()?;
668 let resolution = store::resolve(®istry, repo)?;
669 let slug = publish::change_host(&resolution.key)?;
670 Ok(Self {
671 host: providers.hosting.for_repo(&slug)?,
672 key: resolution.key,
673 publication: resolution.publication,
674 registry,
675 })
676 }
677
678 fn id(&self, url: &Url) -> Result<ChangeId> {
681 let parsed = review::ChangeUrl::parse(url.as_str())?;
682 if parsed.identity != self.key {
683 return Err(invalid(format!(
684 "{url} is a change request of {}, not of {}; every change request named must \
685 be one of the repository the call names",
686 parsed.identity, self.key
687 )));
688 }
689 Ok(ChangeId(parsed.number))
690 }
691
692 fn ids<'a>(&self, urls: impl Iterator<Item = &'a Url>) -> Result<Vec<ChangeId>> {
693 urls.map(|url| self.id(url)).collect()
694 }
695
696 fn answered(&self, ids: &[ChangeId], standings: &[ChangeStanding]) -> Result<()> {
698 let numbers: Vec<Option<String>> = standings
699 .iter()
700 .map(|standing| {
701 review::ChangeUrl::parse(standing.url.as_str())
702 .ok()
703 .map(|url| url.number)
704 })
705 .collect();
706 let asked: Vec<Option<String>> = ids.iter().map(|id| Some(id.0.clone())).collect();
707 if numbers != asked {
708 return Err(invalid(format!(
709 "the host answered about {} change request(s) where {} were asked about, or not \
710 in the order asked, so which standing is which cannot be read",
711 standings.len(),
712 ids.len()
713 )));
714 }
715 Ok(())
716 }
717
718 fn one(&self, id: &ChangeId, root: &str, cost: &mut ReadCost) -> Result<ChangeStanding> {
720 let read = self.host.change_standings(root, std::slice::from_ref(id))?;
721 self.answered(std::slice::from_ref(id), &read.standings)?;
722 add(cost, read.cost);
723 let standing = read.standings.into_iter().next().ok_or_else(|| {
724 invalid(format!(
725 "the host answered nothing about change request {}",
726 id.0
727 ))
728 })?;
729 record_head(&standing.url, &standing.head_branch);
730 Ok(standing)
731 }
732
733 fn origin_branches(&self) -> Result<BTreeMap<String, git::ObjectId>> {
736 git::remote_branch_tips(&self.publication, ORIGIN)?.ok_or_else(|| {
737 invalid(format!(
738 "the origin of {} would not list its branches from {}",
739 self.key,
740 self.publication.display()
741 ))
742 })
743 }
744
745 fn take_up(&self, standings: &[ChangeStanding], root: &str) -> Vec<String> {
751 let mut landed = Vec::new();
752 for standing in standings {
753 record_head(&standing.url, &standing.head_branch);
754 let ChangeState::Merged {
755 into,
756 commit,
757 landing: true,
758 merged_at,
759 } = &standing.state
760 else {
761 continue;
762 };
763 if into != root {
764 continue;
765 }
766 if self.record_landing(standing, commit, merged_at.as_deref()) {
767 landed.push(standing.url.to_string());
768 }
769 }
770 landed
771 }
772
773 fn record_landing(
776 &self,
777 standing: &ChangeStanding,
778 commit: &Sha,
779 merged_at: Option<&str>,
780 ) -> bool {
781 let warn = |failure: &dyn std::fmt::Display| {
782 eprintln!(
783 "onevcs: warning: the host reports {url} merged into the root at {commit}, and \
784 this host could not record that landing for {branch} ({failure})",
785 url = standing.url,
786 commit = commit.0,
787 branch = standing.head_branch,
788 );
789 false
790 };
791 let (recorded, opened_on) = match status::landing_record(&self.key, &standing.head_branch) {
792 Ok(found) => found,
793 Err(failure) => return warn(&failure),
794 };
795 if recorded.as_deref() == Some(commit.0.as_str()) {
796 return false;
797 }
798 let located = match release::for_repository(&self.registry, &self.key) {
799 Ok(located) => located,
800 Err(failure) => return warn(&failure),
801 };
802 let mut releases = match Stream::releases(&self.key) {
803 Ok(stream) => stream,
804 Err(failure) => return warn(&failure),
805 };
806 let own = change_stream(standing.url.as_str());
807 let recording = match &opened_on {
808 Some(token) => release::LandingStream::Opened(token),
809 None => release::LandingStream::Own {
810 token: &own,
811 identity: &self.key,
812 },
813 };
814 release::reconcile_landing(
815 &self.registry,
816 &located,
817 &standing.head_branch,
818 Some(recording),
819 release::Landing {
820 commit: &commit.0,
821 at: merged_at.and_then(crate::gate_run::Moment::reported),
822 },
823 &standing.url,
824 &mut releases,
825 )
826 }
827
828 fn record_read(&self, count: usize, landed: &[String], cost: ReadCost) {
830 let token = format!("standings-{}", ids::short_digest(&self.key));
831 match Stream::open(&token) {
832 Ok(mut stream) => {
833 stream.label("identity", &self.key);
834 stream.emit(
835 EventKind::ChangeStandingsRead,
836 object(json!({
837 "repo": self.key,
838 "count": count,
839 "landed": landed,
840 "cost": {
841 "graphql_points": cost.graphql_points,
842 "rest_requests": cost.rest_requests,
843 },
844 })),
845 );
846 }
847 Err(error) => eprintln!(
848 "onevcs: warning: the standings read of {} is not recorded: {error}",
849 self.key
850 ),
851 }
852 }
853
854 fn record_change(&self, url: &Url, kind: EventKind, payload: serde_json::Value) {
856 match Stream::open(&change_stream(url.as_str())) {
857 Ok(mut stream) => {
858 stream.label("change_url", url.as_str());
859 stream.label("identity", &self.key);
860 stream.emit(kind, object(payload));
861 }
862 Err(error) => {
863 eprintln!("onevcs: warning: what was done on {url} is not recorded: {error}")
864 }
865 }
866 }
867}
868
869pub(crate) fn change_stream(change_url: &str) -> String {
872 format!("change-{}", ids::short_digest(change_url))
873}
874
875fn restored_on(url: &Url, base: &str) -> Result<bool> {
878 let token = change_stream(url.as_str());
879 if !crate::stream::path_for(&token)?.is_file() {
880 return Ok(false);
881 }
882 let mut reader = crate::stream::Reader::open(&token)?;
883 Ok(reader.records()?.into_iter().any(|record| {
884 record.read.is_ok_and(|envelope| {
885 envelope.kind.as_str() == "change-reopened"
886 && envelope
887 .payload
888 .get("restored_base")
889 .and_then(serde_json::Value::as_str)
890 == Some(base)
891 })
892 }))
893}
894
895fn heads_dir() -> Result<PathBuf> {
897 Ok(home::root()?.join("change-heads"))
898}
899
900#[derive(Debug, Clone, Serialize, Deserialize)]
902struct HeadRecord {
903 url: String,
904 head_branch: String,
905 at: String,
906}
907
908pub(crate) fn record_head(url: &Url, head_branch: &str) {
910 let written = (|| -> Result<()> {
911 let directory = heads_dir()?;
912 home::ensure_dir(&directory)?;
913 let record = HeadRecord {
914 url: url.to_string(),
915 head_branch: head_branch.to_owned(),
916 at: ids::timestamp(),
917 };
918 let text = serde_json::to_string(&record)
919 .map_err(|e| invalid(format!("a head record could not be written: {e}")))?;
920 home::atomic_write(
921 &directory.join(format!("{}.json", ids::short_digest(url.as_str()))),
922 &text,
923 )
924 })();
925 if let Err(error) = written {
926 eprintln!("onevcs: warning: the head branch read for {url} is not recorded: {error}");
927 }
928}
929
930pub(crate) fn recorded_head(url: &Url) -> Result<Option<String>> {
935 let mut candidates: Vec<(String, String)> = status::opened_heads(url.as_str())?;
936 let path = heads_dir()?.join(format!("{}.json", ids::short_digest(url.as_str())));
937 match std::fs::read_to_string(&path) {
938 Ok(text) => {
942 let record = serde_json::from_str::<HeadRecord>(&text)
943 .ok()
944 .filter(|record| {
945 record.url == url.as_str()
946 && ids::is_timestamp(&record.at)
947 && Ref::try_from(record.head_branch.clone()).is_ok()
948 })
949 .ok_or_else(|| {
950 invalid(format!(
951 "the head record at {} is not one this build writes for {url}; remove \
952 it, and a standings read of {url} writes it again",
953 path.display()
954 ))
955 })?;
956 candidates.push((record.at, record.head_branch));
957 }
958 Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => {}
959 Err(failure) => {
960 return Err(invalid(format!(
961 "the head record at {} could not be read: {failure}",
962 path.display()
963 )))
964 }
965 }
966 Ok(candidates
967 .into_iter()
968 .max_by(|left, right| left.0.cmp(&right.0))
969 .map(|(_, branch)| branch))
970}