1use std::collections::{BTreeMap, BTreeSet};
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45use serde_json::{json, Map, Value};
46use url::Url;
47
48use crate::error::{self, Error, Result};
49use crate::event::EventKind;
50use crate::git::{self, LocalTip, ObjectId, RemoteTip};
51use crate::host::{ChangeId, ChangeRequest, Hosting, Sha};
52use crate::provenance::{self, Trailers};
53use crate::registry::Registry;
54use crate::session::{Lifecycle, Scope, SessionToken};
55use crate::store::{self, Resolution};
56use crate::stream::Stream;
57use crate::workspace::{self, Record, Ref};
58use crate::{ids, label, landed, lock, policy, pool, processes, status, workspaces};
59
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69pub struct BranchRef {
70 pub identity: String,
72 pub branch: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct RetirementQuery {
79 pub repo: Option<String>,
83 pub branch: String,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum RetireMode {
91 Lossless,
94 Reclaim,
98 Discard,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct RetireRequest {
108 pub repo: Option<String>,
110 pub branch: String,
112 pub mode: RetireMode,
114 pub dry_run: bool,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RetirePass {
121 pub scope: Scope,
123 pub exclude: Vec<BranchRef>,
125 pub dry_run: bool,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Supersession {
132 pub repo: String,
135 pub branch: String,
137 pub superseded_by: String,
139 pub landing: String,
141 pub labels: BTreeMap<String, String>,
143}
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum RetirementClass {
150 Retirable,
152 SupersededWithChanges,
154 Keep,
156}
157
158impl RetirementClass {
159 pub fn as_str(self) -> &'static str {
161 match self {
162 RetirementClass::Retirable => "retirable",
163 RetirementClass::SupersededWithChanges => "superseded-with-changes",
164 RetirementClass::Keep => "keep",
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172pub enum KeepReason {
173 HeldByLiveSession,
176 Excluded,
178 OpenChangeRequest,
180 CheckedOut,
182 DirtyWorktree,
184 UnmergedUniqueCommits,
186 Unknown,
188 IsBase,
190 ClosedChangeRequest,
193}
194
195impl KeepReason {
196 pub fn as_str(self) -> &'static str {
198 match self {
199 KeepReason::HeldByLiveSession => "held-by-live-session",
200 KeepReason::Excluded => "excluded",
201 KeepReason::OpenChangeRequest => "open-change-request",
202 KeepReason::ClosedChangeRequest => "closed-change-request",
203 KeepReason::CheckedOut => "checked-out",
204 KeepReason::DirtyWorktree => "dirty-worktree",
205 KeepReason::UnmergedUniqueCommits => "unmerged-unique-commits",
206 KeepReason::Unknown => "unknown",
207 KeepReason::IsBase => "is-base",
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
227pub struct UnknownCause {
228 pub operation: String,
232 pub subject: String,
234 pub error: String,
236}
237impl UnknownCause {
240 fn new(
241 operation: &str,
242 subject: impl std::fmt::Display,
243 error: impl std::fmt::Display,
244 ) -> Self {
245 UnknownCause {
246 operation: operation.to_owned(),
247 subject: subject.to_string(),
248 error: error.to_string(),
249 }
250 }
251
252 fn unreadable(&self) -> String {
255 format!("{}: {}", self.subject, self.error)
256 }
257}
258
259impl std::fmt::Display for UnknownCause {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 write!(
262 f,
263 "{} failed for {}: {}",
264 self.operation, self.subject, self.error
265 )
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(tag = "kind", rename_all = "kebab-case")]
272pub enum RetirementProof {
273 MergedChangeRequest {
276 change_url: Url,
278 head: Sha,
280 },
281 RecordedLanding {
284 commit: Sha,
286 },
287 ContentIdentical {
289 base_commit: Sha,
291 },
292}
293
294impl RetirementProof {
295 pub fn kind(&self) -> &'static str {
297 match self {
298 RetirementProof::MergedChangeRequest { .. } => "merged-change-request",
299 RetirementProof::RecordedLanding { .. } => "recorded-landing",
300 RetirementProof::ContentIdentical { .. } => "content-identical",
301 }
302 }
303
304 pub fn commit(&self) -> &str {
306 match self {
307 RetirementProof::MergedChangeRequest { head, .. } => &head.0,
308 RetirementProof::RecordedLanding { commit } => &commit.0,
309 RetirementProof::ContentIdentical { base_commit } => &base_commit.0,
310 }
311 }
312
313 pub fn describe(&self) -> String {
315 match self {
316 RetirementProof::MergedChangeRequest { change_url, head } => {
317 format!(
318 "merged-change-request — {change_url} merged at head {}",
319 head.0
320 )
321 }
322 RetirementProof::RecordedLanding { commit } => {
323 format!(
324 "recorded-landing — {} landed, and nothing after it changes content",
325 commit.0
326 )
327 }
328 RetirementProof::ContentIdentical { base_commit } => {
329 format!(
330 "content-identical — every path it changed reads the same on {}",
331 base_commit.0
332 )
333 }
334 }
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct SupersededBy {
350 pub branch: String,
352 pub landing: String,
354 pub labels: BTreeMap<String, String>,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
360#[serde(rename_all = "kebab-case")]
361pub enum BranchHolderKind {
362 Checkout,
364 Slot,
366 RunClone,
368 Origin,
370}
371
372impl BranchHolderKind {
373 pub fn as_str(self) -> &'static str {
375 match self {
376 BranchHolderKind::Checkout => "checkout",
377 BranchHolderKind::Slot => "slot",
378 BranchHolderKind::RunClone => "run-clone",
379 BranchHolderKind::Origin => "origin",
380 }
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
386pub struct BranchHolder {
387 pub kind: BranchHolderKind,
389 pub location: String,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct FailedHolder {
396 pub kind: BranchHolderKind,
398 pub location: String,
400 pub error: String,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(try_from = "AnyRetirement")]
411pub struct Retirement {
412 pub class: RetirementClass,
414 pub reason: Option<KeepReason>,
416 pub cause: Option<UnknownCause>,
419 pub identity: String,
421 pub branch: String,
423 pub tip: Sha,
425 pub base: String,
427 pub proof: Option<RetirementProof>,
430 pub content_free_commits: Vec<Sha>,
432 pub superseded_by: Option<SupersededBy>,
434 pub differing_paths: Vec<String>,
437 pub holders: Vec<BranchHolder>,
439}
440
441#[derive(Deserialize)]
443struct AnyRetirement {
444 class: RetirementClass,
445 reason: Option<KeepReason>,
446 #[serde(default)]
447 cause: Option<UnknownCause>,
448 identity: String,
449 branch: String,
450 tip: Sha,
451 base: String,
452 proof: Option<RetirementProof>,
453 content_free_commits: Vec<Sha>,
454 superseded_by: Option<SupersededBy>,
455 differing_paths: Vec<String>,
456 holders: Vec<BranchHolder>,
457}
458
459impl TryFrom<AnyRetirement> for Retirement {
462 type Error = String;
463
464 fn try_from(any: AnyRetirement) -> std::result::Result<Self, String> {
465 if (any.class == RetirementClass::Keep) != any.reason.is_some() {
466 return Err("a retirement carries a reason exactly when its class is keep".to_owned());
467 }
468 if (any.class == RetirementClass::Retirable) != any.proof.is_some() {
469 return Err(
470 "a retirement carries a proof exactly when its class is retirable".to_owned(),
471 );
472 }
473 if let Some(cause) = &any.cause {
474 if any.reason != Some(KeepReason::Unknown) {
475 return Err(
476 "a retirement carries a cause only when its reason is unknown".to_owned(),
477 );
478 }
479 if [&cause.operation, &cause.subject, &cause.error]
480 .iter()
481 .any(|field| field.trim().is_empty())
482 {
483 return Err(
484 "a retirement's cause names its operation, its subject and its error"
485 .to_owned(),
486 );
487 }
488 }
489 if any.class == RetirementClass::SupersededWithChanges && any.superseded_by.is_none() {
490 return Err("a superseded-with-changes retirement names what superseded it".to_owned());
491 }
492 Ok(Retirement {
493 class: any.class,
494 reason: any.reason,
495 cause: any.cause,
496 identity: any.identity,
497 branch: any.branch,
498 tip: any.tip,
499 base: any.base,
500 proof: any.proof,
501 content_free_commits: any.content_free_commits,
502 superseded_by: any.superseded_by,
503 differing_paths: any.differing_paths,
504 holders: any.holders,
505 })
506 }
507}
508
509impl Retirement {
510 fn kept(census: &Census, branch: &str, reason: KeepReason, copies: &Copies) -> Self {
512 Retirement {
513 class: RetirementClass::Keep,
514 reason: Some(reason),
515 cause: None,
516 identity: census.resolution.key.clone(),
517 branch: branch.to_owned(),
518 tip: Sha(copies.first_tip()),
519 base: census.base.clone().unwrap_or_default(),
520 proof: None,
521 content_free_commits: Vec::new(),
522 superseded_by: None,
523 differing_paths: Vec::new(),
524 holders: census.holders_of(copies),
525 }
526 }
527
528 fn unknown(census: &Census, branch: &str, cause: UnknownCause, copies: &Copies) -> Self {
530 Retirement {
531 cause: Some(cause),
532 ..Retirement::kept(census, branch, KeepReason::Unknown, copies)
533 }
534 }
535
536 pub fn cause(&self) -> Option<&UnknownCause> {
538 self.cause.as_ref()
539 }
540
541 pub fn evidence(&self) -> Vec<String> {
544 let mut lines = Vec::new();
545 if let Some(proof) = &self.proof {
546 lines.push(format!("proof: {}", proof.describe()));
547 }
548 if let Some(by) = &self.superseded_by {
549 lines.push(format!(
550 "superseded by {} (landed at {})",
551 by.branch, by.landing
552 ));
553 if !by.labels.is_empty() {
554 lines.push(format!("labels: {}", spelled_labels(&by.labels)));
555 }
556 }
557 if !self.differing_paths.is_empty() {
558 lines.push(format!(
559 "differs from {} in: {}",
560 self.base,
561 self.differing_paths.join(", ")
562 ));
563 }
564 if !self.content_free_commits.is_empty() {
565 lines.push(format!(
566 "content-free commits at its tip: {}",
567 self.content_free_commits
568 .iter()
569 .map(|commit| commit.0.as_str())
570 .collect::<Vec<_>>()
571 .join(", ")
572 ));
573 }
574 for holder in &self.holders {
575 lines.push(format!(
576 "held in {} {}",
577 holder.kind.as_str(),
578 holder.location
579 ));
580 }
581 lines
582 }
583
584 pub fn verdict(&self) -> String {
587 match (self.reason, &self.cause) {
588 (Some(reason), Some(cause)) => {
589 format!("{} / {}: {cause}", self.class.as_str(), reason.as_str())
590 }
591 (Some(reason), None) => format!("{} / {}", self.class.as_str(), reason.as_str()),
592 (None, _) => self.class.as_str().to_owned(),
593 }
594 }
595}
596
597pub(crate) fn spelled_labels(labels: &BTreeMap<String, String>) -> String {
599 labels
600 .iter()
601 .map(|(key, value)| format!("{key}={value}"))
602 .collect::<Vec<_>>()
603 .join(", ")
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(rename_all = "kebab-case")]
609pub enum RetireOutcome {
610 Retired,
612 WouldRetire,
614 AlreadyRetired,
616 Kept,
618 Incomplete,
620}
621
622impl RetireOutcome {
623 pub fn as_str(self) -> &'static str {
625 match self {
626 RetireOutcome::Retired => "retired",
627 RetireOutcome::WouldRetire => "would-retire",
628 RetireOutcome::AlreadyRetired => "already-retired",
629 RetireOutcome::Kept => "kept",
630 RetireOutcome::Incomplete => "incomplete",
631 }
632 }
633}
634
635#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(rename_all = "kebab-case")]
644pub enum Derivation {
645 #[default]
648 Derived,
649 Reused,
652}
653
654impl Derivation {
655 pub fn as_str(self) -> &'static str {
657 match self {
658 Derivation::Derived => "derived",
659 Derivation::Reused => "reused",
660 }
661 }
662}
663
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct Retired {
674 #[serde(flatten)]
676 pub retirement: Retirement,
677 pub outcome: RetireOutcome,
679 pub deleted: Vec<BranchHolder>,
681 pub failed: Vec<FailedHolder>,
683 pub slots_returned: Vec<PathBuf>,
685 pub run_roots_removed: Vec<PathBuf>,
687 pub sessions_closed: Vec<SessionToken>,
689 #[serde(default)]
699 pub derivation: Derivation,
700}
701
702impl Retired {
703 fn nothing(retirement: Retirement, outcome: RetireOutcome) -> Self {
704 Retired {
705 retirement,
706 outcome,
707 deleted: Vec::new(),
708 failed: Vec::new(),
709 slots_returned: Vec::new(),
710 run_roots_removed: Vec::new(),
711 sessions_closed: Vec::new(),
712 derivation: Derivation::Derived,
713 }
714 }
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719pub struct RetirementPassReport {
720 pub dry_run: bool,
722 pub examined: Vec<Retired>,
724}
725
726pub(crate) fn describe_retired(retired: &Retired, verb: &str) -> Vec<String> {
729 let retirement = &retired.retirement;
730 let named = format!("{} of {}", retirement.branch, retirement.identity);
731 let places = |holders: &[BranchHolder]| {
732 holders
733 .iter()
734 .map(|holder| format!("{} {}", holder.kind.as_str(), holder.location))
735 .collect::<Vec<_>>()
736 .join("; ")
737 };
738 let mut lines = vec![match retired.outcome {
739 RetireOutcome::Retired => format!(
740 "retired: {named} ({}) was deleted from {}",
741 retirement.verdict(),
742 places(&retired.deleted)
743 ),
744 RetireOutcome::WouldRetire => format!(
745 "would retire: {named} ({}) would be deleted from {}. Nothing was changed: this was \
746 a rehearsal",
747 retirement.verdict(),
748 places(&retired.deleted)
749 ),
750 RetireOutcome::AlreadyRetired => format!(
751 "already retired: nothing on this host holds {named} any more, and its retirement \
752 ({}) is recorded",
753 retirement.verdict()
754 ),
755 RetireOutcome::Kept => format!(
756 "refused: {named} is {}, which `onevcs {verb}` does not delete; nothing was deleted",
757 retirement.verdict()
758 ),
759 RetireOutcome::Incomplete => format!(
760 "retired in part: {named} ({}) was deleted from {} and not from {}. Re-run `onevcs \
761 {verb} {} --repo {}` once that is fixed",
762 retirement.verdict(),
763 match retired.deleted.is_empty() {
764 true => "nowhere".to_owned(),
765 false => places(&retired.deleted),
766 },
767 retired
768 .failed
769 .iter()
770 .map(|failed| format!(
771 "{} {} ({})",
772 failed.kind.as_str(),
773 failed.location,
774 failed.error
775 ))
776 .collect::<Vec<_>>()
777 .join("; "),
778 retirement.branch,
779 retirement.identity,
780 ),
781 }];
782 lines.extend(
783 retirement
784 .evidence()
785 .into_iter()
786 .map(|line| format!(" {line}")),
787 );
788 if retired.outcome == RetireOutcome::Kept
789 && retirement.class == RetirementClass::SupersededWithChanges
790 {
791 lines.push(format!(
792 " it differs from {} only in what the retry that superseded it replaced: `onevcs \
793 reclaim {} --repo {}` discards that and deletes it",
794 retirement.base, retirement.branch, retirement.identity
795 ));
796 }
797 for slot in &retired.slots_returned {
798 lines.push(format!(" returned slot {}", slot.display()));
799 }
800 for run_root in &retired.run_roots_removed {
801 lines.push(format!(" removed run root {}", run_root.display()));
802 }
803 for token in &retired.sessions_closed {
804 lines.push(format!(" closed session {}", token.0));
805 }
806 lines
807}
808
809pub(crate) fn describe_line(retired: &Retired) -> String {
811 let retirement = &retired.retirement;
812 let decided = match &retirement.proof {
813 Some(proof) => proof.describe(),
814 None => retirement.verdict(),
815 };
816 let failed = match retired.failed.is_empty() {
817 true => String::new(),
818 false => format!(
819 "; not deleted from {}",
820 retired
821 .failed
822 .iter()
823 .map(|failed| format!("{} ({})", failed.location, failed.error))
824 .collect::<Vec<_>>()
825 .join("; ")
826 ),
827 };
828 format!(
829 "{branch} [{identity}] — {outcome}: {decided}{failed}",
830 branch = retirement.branch,
831 identity = retirement.identity,
832 outcome = retired.outcome.as_str(),
833 )
834}
835
836pub(crate) fn describe_pass(report: &RetirementPassReport) -> Vec<String> {
838 let counted = |outcome: RetireOutcome| {
839 report
840 .examined
841 .iter()
842 .filter(|entry| entry.outcome == outcome)
843 .count()
844 };
845 let retired = match report.dry_run {
846 true => counted(RetireOutcome::WouldRetire),
847 false => counted(RetireOutcome::Retired),
848 };
849 let mut lines = vec![format!(
850 "{} {retired} finished branch(es), kept {}{}.",
851 match report.dry_run {
852 true => "would retire",
853 false => "retired",
854 },
855 counted(RetireOutcome::Kept),
856 match counted(RetireOutcome::Incomplete) {
857 0 => String::new(),
858 partial => format!(", and retired {partial} in part"),
859 }
860 )];
861 if report.dry_run {
862 lines.push("Nothing was changed: this was a rehearsal.".to_owned());
863 }
864 for entry in &report.examined {
865 lines.extend(describe_retired(entry, "retire"));
866 }
867 lines
868}
869
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "kebab-case")]
873pub(crate) enum Trigger {
874 Verb,
876 SessionClose,
878 Sweep,
880 Pass,
883}
884
885impl Trigger {
886 pub(crate) fn as_str(self) -> &'static str {
888 match self {
889 Trigger::Verb => "verb",
890 Trigger::SessionClose => "session-close",
891 Trigger::Sweep => "sweep",
892 Trigger::Pass => "pass",
893 }
894 }
895}
896
897#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(rename_all = "kebab-case")]
901pub(crate) enum Acting {
902 Retire,
903 Reclaim,
904 Discard,
905 Automatic,
906}
907
908impl Acting {
909 pub(crate) fn as_str(self) -> &'static str {
911 match self {
912 Acting::Retire => "retire",
913 Acting::Reclaim => "reclaim",
914 Acting::Discard => "discard",
915 Acting::Automatic => "automatic",
916 }
917 }
918
919 pub(crate) fn permits_class(self, class: RetirementClass) -> bool {
923 match class {
924 RetirementClass::Retirable => true,
925 RetirementClass::SupersededWithChanges => {
926 matches!(self, Acting::Reclaim | Acting::Discard)
927 }
928 RetirementClass::Keep => self == Acting::Discard,
929 }
930 }
931
932 pub(crate) fn permits(self, class: RetirementClass, reason: Option<KeepReason>) -> bool {
936 self.permits_class(class)
937 && (class != RetirementClass::Keep || reason == Some(KeepReason::UnmergedUniqueCommits))
938 }
939}
940
941#[derive(Clone, Copy)]
943pub(crate) struct Ask<'a> {
944 host: Option<&'a dyn Hosting>,
947 fetch_objects: bool,
950 reconcile: bool,
952 remote: bool,
955 exclude: &'a [BranchRef],
956 verdicts: Option<&'a crate::verdict::Store>,
959 merges: LeftMerges,
962}
963
964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
967enum LeftMerges {
968 Leave,
970 Abort,
972}
973
974impl<'a> Ask<'a> {
975 pub(crate) fn offline() -> Ask<'static> {
977 Ask {
978 host: None,
979 fetch_objects: false,
980 reconcile: false,
981 remote: false,
982 exclude: &[],
983 verdicts: None,
984 merges: LeftMerges::Leave,
985 }
986 }
987
988 fn query(host: &'a dyn Hosting) -> Self {
989 Ask {
990 host: Some(host),
991 fetch_objects: true,
992 reconcile: false,
993 remote: true,
994 exclude: &[],
995 verdicts: None,
996 merges: LeftMerges::Leave,
997 }
998 }
999
1000 fn acting(host: Option<&'a dyn Hosting>, dry_run: bool, exclude: &'a [BranchRef]) -> Self {
1001 Ask {
1002 host,
1003 fetch_objects: true,
1004 reconcile: !dry_run,
1005 remote: true,
1006 exclude,
1007 verdicts: None,
1008 merges: match dry_run {
1009 true => LeftMerges::Leave,
1010 false => LeftMerges::Abort,
1011 },
1012 }
1013 }
1014}
1015
1016#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1018pub(crate) enum Reach {
1019 Offline,
1022 Remote,
1025 Fetch,
1028}
1029
1030#[derive(Debug, Clone)]
1032struct Place {
1033 kind: BranchHolderKind,
1034 repo: PathBuf,
1035 slot: Option<PathBuf>,
1037}
1038
1039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1041enum Tips {
1042 Listed,
1044 Now,
1047}
1048
1049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1051enum Holding {
1052 Local(usize),
1053 Origin,
1054}
1055
1056#[derive(Debug, Clone, PartialEq, Eq)]
1058struct Copy {
1059 at: Holding,
1060 tip: String,
1061}
1062
1063#[derive(Debug, Clone, Default, PartialEq, Eq)]
1065struct Copies {
1066 copies: Vec<Copy>,
1067 unreadable: Vec<UnknownCause>,
1068}
1069
1070impl Copies {
1071 fn first_tip(&self) -> String {
1072 self.copies
1073 .first()
1074 .map(|copy| copy.tip.clone())
1075 .unwrap_or_default()
1076 }
1077}
1078
1079pub(crate) struct Census<'a> {
1081 registry: &'a Registry,
1082 resolution: Resolution,
1083 base: Option<String>,
1084 base_tip: Option<String>,
1085 lent: Option<PathBuf>,
1086 origin: Option<String>,
1087 places: Vec<Place>,
1088 unlisted: Vec<UnknownCause>,
1091 unread_base: Option<UnknownCause>,
1093 heads: Vec<std::cell::OnceCell<std::result::Result<BTreeMap<String, String>, String>>>,
1097 tracked: std::cell::OnceCell<std::result::Result<BTreeMap<String, String>, String>>,
1100 origin_heads: Option<std::result::Result<BTreeMap<String, ObjectId>, String>>,
1105 stream_listing: status::Listing,
1108 sessions: &'a [Record],
1109 streams: &'a [status::Recorded],
1110 trailers: &'a Trailers,
1111}
1112
1113impl<'a> Census<'a> {
1114 pub(crate) fn read(
1118 registry: &'a Registry,
1119 identity: &str,
1120 sessions: &'a [Record],
1121 streams: &'a [status::Recorded],
1122 trailers: &'a Trailers,
1123 reach: Reach,
1124 only: Option<&BTreeSet<PathBuf>>,
1125 ) -> Result<Self> {
1126 let resolution = store::resolve(registry, identity)?;
1127 let publication = resolution.publication.clone();
1128 let fetch_failure = match reach {
1132 Reach::Fetch => git::fetch(&publication, "origin").err(),
1133 Reach::Offline | Reach::Remote => None,
1134 };
1135 let fetched = fetch_failure.is_none();
1136 let base = git::default_branch(&publication, "origin");
1137 let origin = git::remote_url(&publication, "origin").ok();
1138 let origin_heads = match (reach, &origin) {
1143 (Reach::Offline, _) | (_, None) => None,
1144 (Reach::Remote | Reach::Fetch, Some(_)) => {
1145 Some(match git::remote_branch_tips(&publication, "origin") {
1146 Ok(Some(tips)) => Ok(tips),
1147 Ok(None) => Err("it could not be asked which branches it has".to_owned()),
1148 Err(failure) => Err(failure.to_string()),
1149 })
1150 }
1151 };
1152 let base_tip = match (reach, base.as_deref()) {
1153 (_, Err(_)) => None,
1154 (Reach::Remote | Reach::Fetch, Ok(base)) => fetched
1155 .then(|| remote_base(&publication, base, origin_heads.as_ref()))
1156 .flatten(),
1157 (Reach::Offline, Ok(base)) => {
1158 git::tip(&publication, &format!("refs/remotes/origin/{base}"))
1159 }
1160 };
1161 let unread_base = match (&base, &base_tip) {
1162 (Ok(_), Some(_)) => None,
1163 (Err(failure), _) => Some(UnknownCause::new(
1164 "census",
1165 publication.display(),
1166 format!("which branch the origin's HEAD names: {failure}"),
1167 )),
1168 (Ok(base), None) => Some(UnknownCause::new(
1169 "census",
1170 publication.display(),
1171 match (&fetch_failure, &origin_heads) {
1172 (Some(failure), _) => format!("fetching the origin: {failure}"),
1173 (None, Some(Err(said))) => format!("listing the origin's branches: {said}"),
1174 (None, _) => format!("where the origin has {base} could not be read"),
1175 },
1176 )),
1177 };
1178 let base = base.ok();
1179 let mut places: Vec<Place> = vec![Place {
1180 kind: BranchHolderKind::Checkout,
1181 repo: publication.clone(),
1182 slot: None,
1183 }];
1184 for checkout in registry.checkouts.values() {
1185 if checkout.identity == resolution.key
1186 && !places.iter().any(|place| place.repo == checkout.path)
1187 {
1188 places.push(Place {
1189 kind: BranchHolderKind::Checkout,
1190 repo: checkout.path.clone(),
1191 slot: None,
1192 });
1193 }
1194 }
1195 let root = workspace::identity_dir(&resolution.key)?;
1196 let mut unlisted = Vec::new();
1197 for (slot, _) in numbered(&pool::pool_dir(&root), &mut unlisted) {
1198 places.push(Place {
1199 kind: BranchHolderKind::Slot,
1200 repo: slot.join("clone"),
1201 slot: Some(slot),
1202 });
1203 }
1204 for (run_root, _) in listed(&root.join("runs"), &mut unlisted) {
1205 let clone = run_root.join("clone");
1206 if clone.exists() && !emptied(&clone) {
1207 places.push(Place {
1208 kind: BranchHolderKind::RunClone,
1209 repo: clone,
1210 slot: None,
1211 });
1212 }
1213 }
1214 for record in sessions {
1215 if record.identity != resolution.key
1216 || places.iter().any(|place| place.repo == record.clone)
1217 || !record.clone.exists()
1218 || (record.slot.is_none() && emptied(&record.clone))
1219 {
1220 continue;
1221 }
1222 places.push(Place {
1223 kind: match record.slot {
1224 Some(_) => BranchHolderKind::Slot,
1225 None => BranchHolderKind::RunClone,
1226 },
1227 repo: record.clone.clone(),
1228 slot: record.slot.map(|_| record.run_root.clone()),
1229 });
1230 }
1231 if let Some(only) = only {
1234 places.retain(|place| only.contains(&place.repo));
1235 }
1236 Ok(Census {
1237 registry,
1238 heads: places.iter().map(|_| std::cell::OnceCell::new()).collect(),
1239 tracked: std::cell::OnceCell::new(),
1240 origin_heads,
1241 stream_listing: status::Listing::Whole,
1242 lent: git::objects_dir(&publication).ok(),
1243 origin,
1244 resolution,
1245 base,
1246 base_tip,
1247 places,
1248 unlisted,
1249 unread_base,
1250 sessions,
1251 streams,
1252 trailers,
1253 })
1254 }
1255
1256 fn publication(&self) -> &Path {
1257 &self.resolution.publication
1258 }
1259
1260 fn asked<'r>(&'r self, repo: &'r Path) -> git::Asked<'r> {
1264 match repo == self.publication() {
1265 true => git::Asked::borrowing(repo, None),
1266 false => git::Asked::borrowing(repo, self.lent.as_deref()),
1267 }
1268 }
1269
1270 fn local_tip(&self, index: usize, branch: &str, tips: Tips) -> LocalTip {
1274 let place = &self.places[index];
1275 if tips == Tips::Now {
1276 return git::unremembered(|| git::local_tip(&place.repo, branch));
1277 }
1278 if !place.repo.exists() {
1279 return LocalTip::Absent;
1280 }
1281 let listed = self.heads[index].get_or_init(|| {
1282 git::heads(&place.repo)
1283 .map(|heads| heads.into_iter().collect())
1284 .map_err(|failure| failure.to_string())
1285 });
1286 match listed {
1287 Ok(heads) => match heads.get(branch).and_then(|tip| ObjectId::parse(tip)) {
1288 Some(tip) => LocalTip::At(tip),
1289 None => LocalTip::Absent,
1290 },
1291 Err(said) => LocalTip::Unreadable(said.clone()),
1292 }
1293 }
1294
1295 fn copies(&self, branch: &str, ask: &Ask<'_>) -> Copies {
1298 self.copies_read(branch, ask, Tips::Listed)
1299 }
1300
1301 fn copies_now(&self, branch: &str, ask: &Ask<'_>) -> Copies {
1305 self.copies_read(branch, ask, Tips::Now)
1306 }
1307
1308 fn copies_read(&self, branch: &str, ask: &Ask<'_>, tips: Tips) -> Copies {
1309 let mut read = Copies {
1310 unreadable: self.unlisted.clone(),
1311 ..Copies::default()
1312 };
1313 for (index, place) in self.places.iter().enumerate() {
1314 match self.local_tip(index, branch, tips) {
1315 LocalTip::At(tip) => read.copies.push(Copy {
1316 at: Holding::Local(index),
1317 tip: tip.as_str().to_owned(),
1318 }),
1319 LocalTip::Absent => {}
1320 LocalTip::Unreadable(said) => {
1321 read.unreadable
1322 .push(UnknownCause::new("copy", place.repo.display(), said))
1323 }
1324 }
1325 }
1326 let Some(origin) = self.origin.as_deref() else {
1327 return read;
1328 };
1329 let unread = |error: String| UnknownCause::new("copy", origin, error);
1330 match (ask.remote, tips) {
1331 (true, Tips::Now) => match git::remote_tip(self.publication(), "origin", branch, &[]) {
1332 Ok(RemoteTip::At(tip)) => read.copies.push(Copy {
1333 at: Holding::Origin,
1334 tip: tip.as_str().to_owned(),
1335 }),
1336 Ok(RemoteTip::Absent) => {}
1337 Ok(RemoteTip::Unknown) => read.unreadable.push(unread(
1338 "the origin could not be asked where the branch is".to_owned(),
1339 )),
1340 Err(failure) => read.unreadable.push(unread(failure.to_string())),
1341 },
1342 (true, Tips::Listed) => match &self.origin_heads {
1343 Some(Ok(tips)) => {
1344 if let Some(tip) = tips.get(branch) {
1345 read.copies.push(Copy {
1346 at: Holding::Origin,
1347 tip: tip.as_str().to_owned(),
1348 });
1349 }
1350 }
1351 Some(Err(said)) => read.unreadable.push(unread(said.clone())),
1352 None => read.unreadable.push(unread(
1353 "the origin was not asked where the branch is".to_owned(),
1354 )),
1355 },
1356 (false, _) => {
1357 let tracked = self.tracked.get_or_init(|| {
1360 git::remote_heads(self.publication(), "origin")
1361 .map_err(|failure| failure.to_string())
1362 });
1363 match tracked {
1364 Ok(tracked) => {
1365 if let Some(tip) = tracked.get(branch) {
1366 read.copies.push(Copy {
1367 at: Holding::Origin,
1368 tip: tip.clone(),
1369 });
1370 }
1371 }
1372 Err(said) => read.unreadable.push(unread(format!(
1373 "the origin's remote-tracking branches: {said}"
1374 ))),
1375 }
1376 }
1377 }
1378 read
1379 }
1380
1381 fn holder(&self, at: Holding) -> BranchHolder {
1382 match at {
1383 Holding::Local(index) => BranchHolder {
1384 kind: self.places[index].kind,
1385 location: self.places[index].repo.display().to_string(),
1386 },
1387 Holding::Origin => BranchHolder {
1388 kind: BranchHolderKind::Origin,
1389 location: self.origin.clone().unwrap_or_default(),
1390 },
1391 }
1392 }
1393
1394 fn holders_of(&self, copies: &Copies) -> Vec<BranchHolder> {
1395 copies
1396 .copies
1397 .iter()
1398 .map(|copy| self.holder(copy.at))
1399 .collect()
1400 }
1401
1402 fn session_of(&self, branch: &str) -> Option<&Record> {
1404 crate::vcs::latest_session(self.sessions, &self.resolution.key, branch)
1405 }
1406
1407 fn live_holder(&self, branch: &str) -> std::result::Result<Option<String>, UnknownCause> {
1410 for record in self.sessions {
1411 if record.identity != self.resolution.key
1412 || record.state != Lifecycle::Open
1413 || !self.places.iter().any(|place| place.repo == record.clone)
1414 {
1415 continue;
1416 }
1417 let over = *record.branch == *branch
1418 || git::checked_out_branch(&record.worktree).is_ok_and(|current| current == branch);
1419 if !over {
1420 continue;
1421 }
1422 let occupied = || {
1423 lock::is_occupied(&record.lease()).map_err(|failure| {
1424 UnknownCause::new("live-holder", record.run_root.display(), failure)
1425 })
1426 };
1427 if record.owner_is_running()
1428 || occupied()?
1429 || !processes::holding(&record.run_root).is_empty()
1430 {
1431 return Ok(Some(record.token.to_string()));
1432 }
1433 }
1434 Ok(None)
1435 }
1436
1437 fn holding<'c>(&'c self, copies: &'c Copies) -> impl Iterator<Item = &'c Place> + 'c {
1440 copies.copies.iter().filter_map(|copy| match copy.at {
1441 Holding::Local(index) => Some(&self.places[index]),
1442 Holding::Origin => None,
1443 })
1444 }
1445
1446 fn checked_out(
1448 &self,
1449 branch: &str,
1450 copies: &Copies,
1451 ) -> std::result::Result<Option<PathBuf>, UnknownCause> {
1452 for place in self.holding(copies) {
1453 if place.kind != BranchHolderKind::Checkout {
1454 continue;
1455 }
1456 for (worktree, on) in worktrees_of(&place.repo)? {
1457 if on.as_deref() == Some(branch) {
1458 return Ok(Some(worktree));
1459 }
1460 }
1461 }
1462 Ok(None)
1463 }
1464
1465 fn abort_merges(&self, branch: &str, copies: &Copies) {
1475 for place in self.holding(copies) {
1476 if place.kind == BranchHolderKind::Checkout {
1477 continue;
1478 }
1479 let Ok(heads) = git::worktree_heads(&place.repo) else {
1480 continue;
1481 };
1482 for (worktree, on) in heads {
1483 if on.as_deref() == Some(branch) && worktree != place.repo {
1484 git::abort_merge(&worktree);
1485 }
1486 }
1487 }
1488 }
1489
1490 fn dirty(
1493 &self,
1494 branch: &str,
1495 copies: &Copies,
1496 ) -> std::result::Result<Option<PathBuf>, UnknownCause> {
1497 for place in self.holding(copies) {
1498 if place.kind == BranchHolderKind::Checkout {
1499 continue;
1500 }
1501 for (worktree, on) in worktrees_of(&place.repo)? {
1502 if on.as_deref() != Some(branch) || worktree == place.repo {
1505 continue;
1506 }
1507 let dirty = worktree.is_dir()
1508 && git::is_dirty(&worktree).map_err(|failure| {
1509 UnknownCause::new("worked-in", worktree.display(), failure)
1510 })?;
1511 if dirty {
1512 return Ok(Some(worktree));
1513 }
1514 }
1515 }
1516 Ok(None)
1517 }
1518
1519 fn classify(&self, branch: &str, copies: Copies, ask: &Ask<'_>) -> Result<Classified> {
1528 let classified = |retirement: Retirement, derivation| Classified {
1529 retirement,
1530 copies: copies.clone(),
1531 derivation,
1532 };
1533 let keep = |reason| {
1534 classified(
1535 Retirement::kept(self, branch, reason, &copies),
1536 Derivation::Derived,
1537 )
1538 };
1539 let unknown = |cause| {
1540 classified(
1541 Retirement::unknown(self, branch, cause, &copies),
1542 Derivation::Derived,
1543 )
1544 };
1545 let (Some(base), Some(base_tip)) = (self.base.as_deref(), self.base_tip.as_deref()) else {
1546 return Ok(unknown(self.unread_base.clone().unwrap_or_else(|| {
1547 UnknownCause::new(
1548 "census",
1549 self.publication().display(),
1550 "the base could not be read",
1551 )
1552 })));
1553 };
1554 if branch == base {
1555 return Ok(keep(KeepReason::IsBase));
1556 }
1557 match self.live_holder(branch) {
1558 Ok(Some(_)) => return Ok(keep(KeepReason::HeldByLiveSession)),
1559 Ok(None) => {}
1560 Err(cause) => return Ok(unknown(cause)),
1561 }
1562 if ask
1563 .exclude
1564 .iter()
1565 .any(|excluded| excluded.identity == self.resolution.key && excluded.branch == branch)
1566 {
1567 return Ok(keep(KeepReason::Excluded));
1568 }
1569 if ask.merges == LeftMerges::Abort {
1570 self.abort_merges(branch, &copies);
1571 }
1572 if let Some(cause) = copies.unreadable.first() {
1573 return Ok(unknown(cause.clone()));
1574 }
1575 let (verdict, derivation) = self.verdict(branch, base, base_tip, &copies, ask);
1576 if verdict.reached == crate::verdict::Reached::Early {
1577 return Ok(classified(verdict.retirement, derivation));
1578 }
1579 let retirement = match self.worked_in(branch, &copies) {
1582 Ok(Some(reason)) => Retirement::kept(self, branch, reason, &copies),
1583 Ok(None) => verdict.retirement,
1584 Err(cause) => Retirement::unknown(self, branch, cause, &copies),
1585 };
1586 Ok(classified(retirement, derivation))
1587 }
1588
1589 fn worked_in(
1592 &self,
1593 branch: &str,
1594 copies: &Copies,
1595 ) -> std::result::Result<Option<KeepReason>, UnknownCause> {
1596 if self.checked_out(branch, copies)?.is_some() {
1597 return Ok(Some(KeepReason::CheckedOut));
1598 }
1599 if self.dirty(branch, copies)?.is_some() {
1600 return Ok(Some(KeepReason::DirtyWorktree));
1601 }
1602 Ok(None)
1603 }
1604
1605 fn verdict(
1609 &self,
1610 branch: &str,
1611 base: &str,
1612 base_tip: &str,
1613 copies: &Copies,
1614 ask: &Ask<'_>,
1615 ) -> (crate::verdict::Verdict, Derivation) {
1616 let recorded = ask
1617 .verdicts
1618 .and_then(|store| Some((store, self.key(branch, base, base_tip, copies, ask)?)));
1619 let mut replies = None;
1620 if let Some((store, key)) = &recorded {
1621 if let Some(verdict) = store
1622 .read(key)
1623 .filter(|verdict| self.describes(&verdict.retirement, branch, base, copies))
1624 {
1625 if !verdict.consulted {
1626 return (verdict, Derivation::Reused);
1627 }
1628 let mut evidence = self.evidence(branch);
1632 let repo = copies
1633 .copies
1634 .first()
1635 .and_then(|copy| self.readable(copy, branch, ask));
1636 let (heard, _) = self.consult(
1637 branch,
1638 &mut evidence,
1639 &copies.first_tip(),
1640 repo.as_deref(),
1641 ask,
1642 None,
1643 );
1644 if !heard.failed() && heard.same_as(&verdict.host) {
1645 return (verdict, Derivation::Reused);
1646 }
1647 replies = Some(heard);
1648 }
1649 }
1650 let derived = self.derive(branch, base_tip, copies, ask, replies.as_ref());
1651 let reusable =
1652 derived.retirement.reason != Some(KeepReason::Unknown) && !derived.host.failed();
1653 if let (Some((store, key)), true) = (&recorded, reusable) {
1654 store.write(key, &derived);
1655 }
1656 (derived, Derivation::Derived)
1657 }
1658
1659 fn key(
1662 &self,
1663 branch: &str,
1664 base: &str,
1665 base_tip: &str,
1666 copies: &Copies,
1667 ask: &Ask<'_>,
1668 ) -> Option<crate::verdict::Key> {
1669 if self.stream_listing == status::Listing::Partial {
1670 return None;
1671 }
1672 let session = self
1673 .session_of(branch)
1674 .map(|record| record.token.to_string());
1675 let records = status::recorded_digest(
1676 self.streams,
1677 &self.resolution.key,
1678 branch,
1679 session.as_deref(),
1680 )?;
1681 Some(crate::verdict::Key {
1682 identity: self.resolution.key.clone(),
1683 branch: branch.to_owned(),
1684 base: base.to_owned(),
1685 base_tip: Sha(base_tip.to_owned()),
1686 copies: copies
1687 .copies
1688 .iter()
1689 .map(|copy| {
1690 let holder = self.holder(copy.at);
1691 crate::verdict::KeyedCopy {
1692 kind: holder.kind,
1693 location: holder.location,
1694 tip: Sha(copy.tip.clone()),
1695 }
1696 })
1697 .collect(),
1698 unreadable: copies
1699 .unreadable
1700 .iter()
1701 .map(UnknownCause::unreadable)
1702 .collect(),
1703 records,
1704 session,
1705 landed_trailer: self.trailers.landed().to_owned(),
1706 asking: match ask.host {
1707 Some(_) => crate::verdict::Asking::Host,
1708 None => crate::verdict::Asking::Records,
1709 },
1710 })
1711 }
1712
1713 fn describes(
1716 &self,
1717 retirement: &Retirement,
1718 branch: &str,
1719 base: &str,
1720 copies: &Copies,
1721 ) -> bool {
1722 retirement.identity == self.resolution.key
1723 && retirement.branch == branch
1724 && retirement.base == base
1725 && retirement.tip.0 == copies.first_tip()
1726 && retirement.holders == self.holders_of(copies)
1727 }
1728
1729 fn evidence(&self, branch: &str) -> Evidence {
1730 let session = self
1731 .session_of(branch)
1732 .map(|record| record.token.to_string());
1733 Evidence::of(self, branch, session.as_deref())
1734 }
1735
1736 fn derive(
1740 &self,
1741 branch: &str,
1742 base_tip: &str,
1743 copies: &Copies,
1744 ask: &Ask<'_>,
1745 replies: Option<&crate::verdict::HostAnswer>,
1746 ) -> crate::verdict::Verdict {
1747 let kept = |reason| Retirement::kept(self, branch, reason, copies);
1748 let unknown = |cause| Retirement::unknown(self, branch, cause, copies);
1749 let mut verdict = crate::verdict::Verdict {
1750 retirement: kept(KeepReason::Unknown),
1751 reached: crate::verdict::Reached::Early,
1752 consulted: false,
1753 host: crate::verdict::HostAnswer::not_asked(),
1754 };
1755 let mut evidence = self.evidence(branch);
1756 let mut judged = match self.judged(branch, base_tip, copies, &evidence, ask) {
1757 Ok(judged) => judged,
1758 Err(cause) => {
1759 verdict.retirement = unknown(cause);
1760 return verdict;
1761 }
1762 };
1763 if judged.iter().all(|(_, one)| one.at_base) {
1764 verdict.retirement = kept(KeepReason::IsBase);
1765 return verdict;
1766 }
1767 let (open, consulted, heard) =
1768 self.open_change(branch, &mut evidence, &judged, ask, replies);
1769 verdict.consulted = consulted;
1770 verdict.host = heard;
1771 match open {
1772 Ok(None) => {}
1773 Ok(Some(reason)) => {
1774 verdict.retirement = kept(reason);
1775 return verdict;
1776 }
1777 Err(cause) => {
1778 verdict.retirement = unknown(cause);
1779 return verdict;
1780 }
1781 }
1782 verdict.reached = crate::verdict::Reached::Concluded;
1783 if evidence.changed {
1786 match self.judged(branch, base_tip, copies, &evidence, ask) {
1787 Ok(again) => judged = again,
1788 Err(cause) => {
1789 verdict.retirement = unknown(cause);
1790 return verdict;
1791 }
1792 }
1793 }
1794 verdict.retirement = match self.conclude(branch, base_tip, copies, &judged, ask) {
1795 Ok(concluded) => concluded,
1796 Err(cause) => unknown(cause),
1797 };
1798 verdict
1799 }
1800
1801 fn judged(
1804 &self,
1805 branch: &str,
1806 base_tip: &str,
1807 copies: &Copies,
1808 evidence: &Evidence,
1809 ask: &Ask<'_>,
1810 ) -> std::result::Result<Vec<(Copy, Judged)>, UnknownCause> {
1811 let mut judged = Vec::new();
1812 for copy in &copies.copies {
1813 let Some(repo) = self.readable(copy, branch, ask) else {
1814 return Err(UnknownCause::new(
1815 "judge",
1816 self.holder(copy.at).location,
1817 format!("no repository on this host has its commit {}", copy.tip),
1818 ));
1819 };
1820 let one = self
1821 .judge_copy(self.asked(&repo), ©.tip, base_tip, evidence)
1822 .map_err(|failure| UnknownCause::new("judge", repo.display(), failure))?;
1823 judged.push((copy.clone(), one));
1824 }
1825 Ok(judged)
1826 }
1827
1828 fn conclude(
1830 &self,
1831 branch: &str,
1832 base_tip: &str,
1833 copies: &Copies,
1834 judged: &[(Copy, Judged)],
1835 ask: &Ask<'_>,
1836 ) -> std::result::Result<Retirement, UnknownCause> {
1837 let kept = |reason| Retirement::kept(self, branch, reason, copies);
1838 let mut free: Vec<String> = Vec::new();
1839 let mut differing: BTreeSet<String> = BTreeSet::new();
1840 let mut proof: Option<RetirementProof> = None;
1841 let mut uncovered: Option<(PathBuf, String, Option<String>)> = None;
1842 for (copy, one) in judged {
1843 for commit in &one.content_free {
1844 if !free.contains(commit) {
1845 free.push(commit.clone());
1846 }
1847 }
1848 if one.at_base {
1849 continue;
1850 }
1851 match &one.proof {
1852 Some(found) => {
1853 proof.get_or_insert_with(|| found.clone());
1854 }
1855 None => {
1856 differing.extend(one.differing.iter().cloned());
1857 if uncovered.is_none() {
1858 let repo = self.readable(copy, branch, ask).unwrap_or_default();
1859 uncovered = Some((repo, copy.tip.clone(), one.fork.clone()));
1860 }
1861 }
1862 }
1863 }
1864 let mut retirement = kept(KeepReason::UnmergedUniqueCommits);
1865 retirement.content_free_commits = free.into_iter().map(Sha).collect();
1866 let Some((repo, tip, fork)) = uncovered else {
1867 retirement.class = RetirementClass::Retirable;
1868 retirement.reason = None;
1869 retirement.proof = proof;
1870 if retirement.proof.is_none() {
1873 retirement.proof = Some(RetirementProof::ContentIdentical {
1874 base_commit: Sha(base_tip.to_owned()),
1875 });
1876 }
1877 return Ok(retirement);
1878 };
1879 retirement.differing_paths = differing.into_iter().collect();
1880 let superseded = self
1886 .superseded(self.asked(&repo), branch, base_tip, &tip, fork.as_deref())
1887 .map_err(|failure| UnknownCause::new("supersession", repo.display(), failure))?;
1888 if let Some(by) = superseded {
1889 retirement.class = RetirementClass::SupersededWithChanges;
1890 retirement.reason = None;
1891 retirement.superseded_by = Some(by);
1892 }
1893 Ok(retirement)
1894 }
1895
1896 fn readable(&self, copy: &Copy, branch: &str, ask: &Ask<'_>) -> Option<PathBuf> {
1901 if let Holding::Local(index) = copy.at {
1902 return Some(self.places[index].repo.clone());
1903 }
1904 let wanted = Sha(copy.tip.clone());
1905 let local_twin = self.places.iter().enumerate().find(|(index, _)| {
1908 matches!(self.local_tip(*index, branch, Tips::Listed), LocalTip::At(tip) if tip.as_str() == copy.tip)
1909 });
1910 if let Some((_, place)) = local_twin {
1911 return Some(place.repo.clone());
1912 }
1913 let found = self
1914 .places
1915 .iter()
1916 .find(|place| place.repo.exists() && git::has_commit(self.asked(&place.repo), &wanted))
1917 .map(|place| place.repo.clone());
1918 if found.is_some() || !ask.fetch_objects {
1919 return found;
1920 }
1921 let publication = self.publication().to_path_buf();
1922 match git::fetch_objects_of(&publication, "origin", branch) {
1923 Ok(true) if git::has_commit(self.asked(&publication), &wanted) => Some(publication),
1924 _ => None,
1925 }
1926 }
1927
1928 fn judge_copy(
1930 &self,
1931 asked: git::Asked<'_>,
1932 tip: &str,
1933 base_tip: &str,
1934 evidence: &Evidence,
1935 ) -> Result<Judged> {
1936 let fork = git::merge_base(asked, base_tip, tip)?;
1939 if fork.as_deref() == Some(tip) {
1940 return Ok(Judged::at_base());
1941 }
1942 let (content_free, content_tip) =
1943 git::content_free_tail(asked, tip, fork.as_deref().unwrap_or(""))?;
1944 let history = match &fork {
1945 Some(fork) => git::log_messages(asked, fork, base_tip)?,
1946 None => Vec::new(),
1947 };
1948 let mut proof = None;
1949 if let Some(merged) = &evidence.merged {
1953 if git::known_to_reach(asked, &content_tip, merged.head.as_str())? {
1954 proof = Some(RetirementProof::MergedChangeRequest {
1955 change_url: merged.change.clone(),
1956 head: Sha(merged.head.as_str().to_owned()),
1957 });
1958 }
1959 }
1960 if proof.is_none() {
1961 proof = self.recorded_landing(asked, tip, base_tip, &history, evidence)?;
1962 }
1963 let mut differing = Vec::new();
1966 if proof.is_none() {
1967 differing = match &fork {
1968 Some(fork) => {
1969 let paths = git::changed_paths(asked, fork, tip)?;
1970 git::differing_among(asked, tip, base_tip, &paths)?
1971 }
1972 None => git::changed_paths(asked, base_tip, tip)?,
1973 };
1974 if differing.is_empty() {
1975 proof = Some(RetirementProof::ContentIdentical {
1976 base_commit: Sha(base_tip.to_owned()),
1977 });
1978 }
1979 }
1980 Ok(Judged {
1981 at_base: false,
1982 fork,
1983 content_free,
1984 differing,
1985 proof,
1986 })
1987 }
1988
1989 fn recorded_landing(
1994 &self,
1995 asked: git::Asked<'_>,
1996 tip: &str,
1997 base_tip: &str,
1998 history: &[git::CommitMessage],
1999 evidence: &Evidence,
2000 ) -> Result<Option<RetirementProof>> {
2001 let key = self.trailers.landed();
2002 let mut landed_points: Vec<ObjectId> = Vec::new();
2003 for commit in history {
2004 landed_points.extend(landed::trailer_values(&commit.message, key));
2005 }
2006 if let Some(landing) = evidence.landing.as_ref().map(|landing| &landing.commit) {
2007 if git::known_to_reach(asked, landing.as_str(), base_tip)? {
2008 if let Some(message) = git::commit_message(asked, landing.as_str())? {
2009 landed_points.extend(landed::trailer_values(&message, key));
2010 }
2011 }
2012 }
2013 for point in landed_points {
2014 if !git::known_to_reach(asked, point.as_str(), tip)? {
2015 continue;
2016 }
2017 let mut after = git::commits_in(asked, &format!("{}..{tip}", point.as_str()))?;
2018 after.retain(|commit| commit != point.as_str());
2019 let mut all_free = true;
2020 for commit in &after {
2021 if !git::changes_no_content(asked, commit)? {
2022 all_free = false;
2023 break;
2024 }
2025 }
2026 if all_free {
2027 return Ok(Some(RetirementProof::RecordedLanding {
2028 commit: Sha(point.as_str().to_owned()),
2029 }));
2030 }
2031 }
2032 Ok(None)
2033 }
2034
2035 fn open_change(
2046 &self,
2047 branch: &str,
2048 evidence: &mut Evidence,
2049 judged: &[(Copy, Judged)],
2050 ask: &Ask<'_>,
2051 replies: Option<&crate::verdict::HostAnswer>,
2052 ) -> (
2053 std::result::Result<Option<KeepReason>, UnknownCause>,
2054 bool,
2055 crate::verdict::HostAnswer,
2056 ) {
2057 if evidence.change.is_none() {
2058 return (Ok(None), false, crate::verdict::HostAnswer::not_asked());
2059 }
2060 let covered = evidence.merged.is_some()
2061 && judged.iter().all(|(_, one)| {
2062 one.at_base
2063 || matches!(one.proof, Some(RetirementProof::MergedChangeRequest { .. }))
2064 });
2065 if covered {
2066 return (Ok(None), false, crate::verdict::HostAnswer::not_asked());
2067 }
2068 let first = judged.first().map(|(copy, _)| copy);
2069 let head = first.map(|copy| copy.tip.clone()).unwrap_or_default();
2070 let repo = first.and_then(|copy| self.readable(copy, branch, ask));
2071 let (heard, open) = self.consult(branch, evidence, &head, repo.as_deref(), ask, replies);
2072 (open, true, heard)
2073 }
2074
2075 fn consult(
2088 &self,
2089 branch: &str,
2090 evidence: &mut Evidence,
2091 head: &str,
2092 repo: Option<&Path>,
2093 ask: &Ask<'_>,
2094 replies: Option<&crate::verdict::HostAnswer>,
2095 ) -> (
2096 crate::verdict::HostAnswer,
2097 std::result::Result<Option<KeepReason>, UnknownCause>,
2098 ) {
2099 use crate::verdict::{ChangeState, HostAnswer, Reply};
2100 let mut heard = HostAnswer::not_asked();
2101 let failure = std::cell::RefCell::new(String::new());
2103 let failed = |said: Error| *failure.borrow_mut() = said.to_string();
2104 let session = self
2105 .session_of(branch)
2106 .map(|record| record.token.to_string());
2107 let opened = status::opened_change(
2108 self.streams,
2109 &self.resolution.key,
2110 branch,
2111 session.as_deref(),
2112 );
2113 if ask.host.is_none() {
2114 return (heard, Ok(Some(KeepReason::OpenChangeRequest)));
2115 }
2116 let Some(opened) = opened else {
2117 return (heard, Ok(Some(KeepReason::OpenChangeRequest)));
2118 };
2119 let unanswered = |error: &str| UnknownCause::new("host-consult", &opened.url, error);
2120 let (Some(id), Some(target)) = (&opened.id, &opened.base) else {
2121 return (
2122 heard,
2123 Err(unanswered(
2124 "its opening is recorded without the change request's id or base",
2125 )),
2126 );
2127 };
2128 let host = ask.host.map(|hosting| {
2129 crate::publish::change_host(&self.resolution.key)
2130 .and_then(|slug| hosting.for_repo(&slug))
2131 });
2132 let host = match host {
2133 Some(Ok(host)) => host,
2134 Some(Err(said)) => return (heard, Err(unanswered(&said.to_string()))),
2135 None => return (heard, Ok(Some(KeepReason::OpenChangeRequest))),
2136 };
2137 let change = change_request(&opened, id, target, head);
2138 heard.merged = match replies {
2139 Some(replied) if matches!(replied.merged, Reply::Answered(_)) => replied.merged.clone(),
2140 _ => match host.merged_head(&change) {
2141 Ok(merged) => Reply::Answered(merged),
2142 Err(said) => {
2143 failed(said);
2144 Reply::Failed
2145 }
2146 },
2147 };
2148 match &heard.merged {
2149 Reply::Answered(Some(merged)) => {
2150 let carried = repo.is_some_and(|repo| {
2151 git::carries_content_of(self.asked(repo), &merged.head.0, head).unwrap_or(false)
2152 });
2153 if let (true, Some(repo)) = (carried && ask.reconcile, repo) {
2154 let _ = crate::publish::record_late_merge(
2155 self.registry,
2156 &crate::publish::Watched {
2157 identity: &self.resolution.key,
2158 branch,
2159 url: &opened.url,
2160 id: &id.0,
2161 base: target,
2162 head,
2163 repo,
2164 stream: opened.stream.as_deref(),
2165 },
2166 merged,
2167 crate::publish::late_merge_time(host.as_ref(), &change),
2168 );
2169 }
2170 if let Some(merged_head) = ObjectId::parse(&merged.head.0) {
2171 evidence.merged = Some(Merged {
2172 change: opened.url.clone(),
2173 head: merged_head,
2174 });
2175 evidence.changed = true;
2176 }
2177 return (heard, Ok(None));
2180 }
2181 Reply::Answered(None) => {}
2182 Reply::Failed => {
2183 let cause = unanswered(&format!("whether it merged: {}", failure.borrow()));
2184 return (heard, Err(cause));
2185 }
2186 Reply::NotAsked => {}
2187 }
2188 heard.open = match replies {
2189 Some(replied) if matches!(replied.open, Reply::Answered(_)) => replied.open.clone(),
2190 _ => match host.find_changes(branch, target) {
2191 Ok(open) => {
2192 Reply::Answered(match open.iter().any(|found| found.url == opened.url) {
2193 true => ChangeState::Open,
2194 false => ChangeState::NotOpen,
2195 })
2196 }
2197 Err(said) => {
2198 failed(said);
2199 Reply::Failed
2200 }
2201 },
2202 };
2203 let open = match heard.open {
2204 Reply::Answered(ChangeState::Open) => Ok(Some(KeepReason::OpenChangeRequest)),
2205 Reply::Answered(ChangeState::NotOpen) => Ok(Some(KeepReason::ClosedChangeRequest)),
2208 Reply::Failed => Err(unanswered(&format!(
2209 "whether it is still open: {}",
2210 failure.borrow()
2211 ))),
2212 Reply::NotAsked => Err(unanswered("whether it is still open was not asked")),
2213 };
2214 (heard, open)
2215 }
2216
2217 fn superseded(
2220 &self,
2221 asked: git::Asked<'_>,
2222 branch: &str,
2223 base_tip: &str,
2224 tip: &str,
2225 fork: Option<&str>,
2226 ) -> Result<Option<SupersededBy>> {
2227 let recorded = status::supersessions(self.streams, &self.resolution.key, branch);
2228 for record in recorded.iter().rev() {
2229 let reached = match &record.landing {
2230 SupersedingLanding::Commit(commit) => {
2231 git::known_to_reach(asked, commit.as_str(), base_tip)?
2232 }
2233 SupersedingLanding::Change(url) => {
2234 let from = match fork {
2235 Some(fork) => fork.to_owned(),
2236 None => git::merge_base(asked, base_tip, tip)?.unwrap_or_default(),
2237 };
2238 let history = match from.is_empty() {
2239 true => Vec::new(),
2240 false => git::log_messages(asked, &from, base_tip)?,
2241 };
2242 landed::names_the_change(&history, url.as_str(), &record.superseded_by)
2243 .is_some()
2244 }
2245 };
2246 if reached {
2247 return Ok(Some(SupersededBy {
2248 branch: record.superseded_by.to_string(),
2249 landing: record.landing.to_string(),
2250 labels: record.labels.clone(),
2251 }));
2252 }
2253 }
2254 Ok(None)
2255 }
2256}
2257
2258fn remote_base(
2262 publication: &Path,
2263 base: &str,
2264 listed: Option<&std::result::Result<BTreeMap<String, ObjectId>, String>>,
2265) -> Option<String> {
2266 let tip = listed?.as_ref().ok()?.get(base)?;
2267 let wanted = Sha(tip.as_str().to_owned());
2268 if !git::has_commit(publication, &wanted) {
2269 git::fetch_objects_of(publication, "origin", base).ok()?;
2270 }
2271 git::has_commit(publication, &wanted).then(|| tip.as_str().to_owned())
2272}
2273
2274fn worktrees_of(repo: &Path) -> std::result::Result<Vec<(PathBuf, Option<String>)>, UnknownCause> {
2277 git::worktree_heads(repo)
2278 .map_err(|failure| UnknownCause::new("worked-in", repo.display(), failure))
2279}
2280
2281struct Classified {
2284 retirement: Retirement,
2285 copies: Copies,
2286 derivation: Derivation,
2287}
2288
2289struct Judged {
2291 at_base: bool,
2292 fork: Option<String>,
2293 content_free: Vec<String>,
2294 differing: Vec<String>,
2295 proof: Option<RetirementProof>,
2296}
2297
2298impl Judged {
2299 fn at_base() -> Self {
2300 Judged {
2301 at_base: true,
2302 fork: None,
2303 content_free: Vec::new(),
2304 differing: Vec::new(),
2305 proof: None,
2306 }
2307 }
2308}
2309
2310struct Evidence {
2312 change: Option<Url>,
2314 landing: Option<landed::RecordedLanding>,
2316 merged: Option<Merged>,
2319 changed: bool,
2322}
2323
2324struct Merged {
2326 change: Url,
2327 head: ObjectId,
2328}
2329
2330impl Evidence {
2331 fn of(census: &Census<'_>, branch: &str, session: Option<&str>) -> Self {
2332 let recorded =
2333 status::recorded_for(census.streams, &census.resolution.key, branch, session);
2334 let merged = recorded.landing.as_ref().and_then(|landing| {
2339 let change = landing.change.clone()?;
2340 (Some(&change) == recorded.change.as_ref()).then_some(())?;
2341 Some(Merged {
2342 change,
2343 head: landing.head.clone()?,
2344 })
2345 });
2346 Evidence {
2347 change: recorded.change,
2348 landing: recorded.landing,
2349 merged,
2350 changed: false,
2351 }
2352 }
2353}
2354
2355fn change_request(
2356 opened: &status::OpenedChange,
2357 id: &ChangeId,
2358 base: &Ref,
2359 head: &str,
2360) -> ChangeRequest {
2361 ChangeRequest {
2362 id: id.clone(),
2363 url: opened.url.clone(),
2364 head_sha: Sha(head.to_owned()),
2365 base: base.to_string(),
2366 }
2367}
2368
2369fn numbered(directory: &Path, unlisted: &mut Vec<UnknownCause>) -> Vec<(PathBuf, u32)> {
2371 let mut found: Vec<(PathBuf, u32)> = listed(directory, unlisted)
2372 .into_iter()
2373 .filter_map(|(path, name)| {
2374 name.parse::<u32>()
2375 .ok()
2376 .filter(|number| *number > 0)
2377 .map(|number| (path, number))
2378 })
2379 .collect();
2380 found.sort_by_key(|(_, number)| *number);
2381 found
2382}
2383
2384fn listed(directory: &Path, unlisted: &mut Vec<UnknownCause>) -> Vec<(PathBuf, String)> {
2388 let entries = match std::fs::read_dir(directory) {
2389 Ok(entries) => entries,
2390 Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
2391 Err(failure) => {
2392 unlisted.push(UnknownCause::new("census", directory.display(), failure));
2393 return Vec::new();
2394 }
2395 };
2396 let mut found = Vec::new();
2397 for entry in entries {
2398 match entry {
2399 Ok(entry) if entry.path().is_dir() => found.push((
2400 entry.path(),
2401 entry.file_name().to_string_lossy().into_owned(),
2402 )),
2403 Ok(_) => {}
2404 Err(failure) => {
2405 unlisted.push(UnknownCause::new("census", directory.display(), failure))
2406 }
2407 }
2408 }
2409 found.sort();
2410 found
2411}
2412
2413fn is_identity_key(identity: &str) -> bool {
2417 if identity.trim() != identity || identity.chars().any(char::is_control) {
2418 return false;
2419 }
2420 let normalized = store::normalize(identity);
2421 match normalized.hosted {
2422 Some(_) => normalized.key == identity,
2423 None => Path::new(identity).is_absolute(),
2424 }
2425}
2426
2427#[derive(Debug, Clone, PartialEq, Eq)]
2429pub(crate) enum SupersedingLanding {
2430 Commit(ObjectId),
2431 Change(Url),
2432}
2433
2434impl SupersedingLanding {
2435 fn parse(value: &str) -> Option<Self> {
2436 if let Some(commit) = ObjectId::parse(value) {
2437 return Some(SupersedingLanding::Commit(commit));
2438 }
2439 Url::parse(value)
2440 .ok()
2441 .filter(|url| matches!(url.scheme(), "http" | "https"))
2442 .map(SupersedingLanding::Change)
2443 }
2444}
2445
2446impl std::fmt::Display for SupersedingLanding {
2447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2448 match self {
2449 SupersedingLanding::Commit(commit) => f.write_str(commit.as_str()),
2450 SupersedingLanding::Change(url) => f.write_str(url.as_str()),
2451 }
2452 }
2453}
2454
2455#[derive(Debug, Clone, PartialEq, Eq)]
2457pub(crate) struct SupersessionRecord {
2458 identity: String,
2459 branch: Ref,
2460 superseded_by: Ref,
2461 landing: SupersedingLanding,
2462 labels: BTreeMap<String, String>,
2463}
2464
2465impl SupersessionRecord {
2466 pub(crate) fn read(payload: &Map<String, Value>) -> Option<Self> {
2470 let text = |name: &str| payload.get(name).and_then(Value::as_str).map(str::to_owned);
2471 let labels: BTreeMap<String, String> = match payload.get("labels") {
2472 Some(Value::Object(map)) => map
2473 .iter()
2474 .map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned())))
2475 .collect::<Option<_>>()?,
2476 None | Some(Value::Null) => BTreeMap::new(),
2477 Some(_) => return None,
2478 };
2479 label::validate(&labels).ok()?;
2480 Some(SupersessionRecord {
2481 identity: text("identity").filter(|identity| is_identity_key(identity))?,
2482 branch: Ref::try_from(text("branch")?).ok()?,
2483 superseded_by: Ref::try_from(text("superseded_by")?).ok()?,
2484 landing: SupersedingLanding::parse(&text("landing")?)?,
2485 labels,
2486 })
2487 }
2488
2489 pub(crate) fn names(&self, identity: &str, branch: &str) -> bool {
2490 self.identity == identity && *self.branch == *branch
2491 }
2492
2493 pub(crate) fn branch(&self) -> &str {
2494 &self.branch
2495 }
2496}
2497
2498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2507pub(crate) struct RetiredPayload {
2508 identity: String,
2509 branch: String,
2510 tip: Sha,
2511 class: RetirementClass,
2512 reason: Option<KeepReason>,
2513 proof: Option<RetirementProof>,
2514 superseded_by: Option<SupersededBy>,
2515 differing_paths: Vec<String>,
2516 mode: Acting,
2517 trigger: Trigger,
2518 deleted: Vec<BranchHolder>,
2519 failed: Vec<FailedHolder>,
2520 slots_returned: Vec<PathBuf>,
2521 run_roots_removed: Vec<PathBuf>,
2522 sessions_closed: Vec<String>,
2523}
2524impl RetiredPayload {
2527 fn accounts_plainly(&self) -> bool {
2532 let one_line = |text: &str| !text.is_empty() && !text.chars().any(char::is_control);
2533 let place = |kind: BranchHolderKind, location: &str| {
2534 one_line(location)
2535 && (kind == BranchHolderKind::Origin || Path::new(location).is_absolute())
2536 };
2537 let path = |path: &PathBuf| path.to_str().is_some_and(one_line) && path.is_absolute();
2538 self.deleted
2539 .iter()
2540 .all(|holder| place(holder.kind, &holder.location))
2541 && self.failed.iter().all(|holder| {
2542 place(holder.kind, &holder.location) && !holder.error.trim().is_empty()
2543 })
2544 && self.slots_returned.iter().all(path)
2545 && self.run_roots_removed.iter().all(path)
2546 && self
2547 .sessions_closed
2548 .iter()
2549 .all(|token| ids::is_safe_name(token))
2550 && self.differing_paths.iter().all(|path| !path.is_empty())
2551 && self.evidence_is_plain()
2552 }
2553
2554 fn evidence_is_plain(&self) -> bool {
2560 let commit = |sha: &Sha| ObjectId::parse(&sha.0).is_some();
2561 let proof = match &self.proof {
2562 None => true,
2563 Some(RetirementProof::MergedChangeRequest { head, .. }) => commit(head),
2564 Some(RetirementProof::RecordedLanding { commit: at }) => commit(at),
2565 Some(RetirementProof::ContentIdentical { base_commit }) => commit(base_commit),
2566 };
2567 let superseded = self.superseded_by.as_ref().is_none_or(|by| {
2568 Ref::try_from(by.branch.clone()).is_ok()
2569 && SupersedingLanding::parse(&by.landing).is_some()
2570 && label::validate(&by.labels).is_ok()
2571 });
2572 proof && superseded
2573 }
2574}
2575
2576#[derive(Debug, Clone, PartialEq, Eq)]
2578pub(crate) struct RetiredRecord {
2579 payload: RetiredPayload,
2580 at: String,
2581}
2582
2583impl RetiredRecord {
2584 pub(crate) fn read(payload: &Map<String, Value>, at: &status::Stamp) -> Option<Self> {
2585 let payload: RetiredPayload =
2586 serde_json::from_value(Value::Object(payload.clone())).ok()?;
2587 Ref::try_from(payload.branch.clone()).ok()?;
2588 ObjectId::parse(&payload.tip.0)?;
2589 if !is_identity_key(&payload.identity) || !payload.accounts_plainly() {
2590 return None;
2591 }
2592 let consistent = payload.mode.permits(payload.class, payload.reason)
2596 && (payload.class == RetirementClass::Keep) == payload.reason.is_some()
2597 && (payload.class == RetirementClass::Retirable) == payload.proof.is_some()
2598 && (payload.class == RetirementClass::SupersededWithChanges)
2599 == payload.superseded_by.is_some();
2600 if !consistent {
2601 return None;
2602 }
2603 Some(RetiredRecord {
2604 payload,
2605 at: String::from(at.clone()),
2606 })
2607 }
2608
2609 pub(crate) fn names(&self, identity: &str, branch: &str) -> bool {
2610 self.payload.identity == identity && self.payload.branch == branch
2611 }
2612
2613 pub(crate) fn identity(&self) -> &str {
2614 &self.payload.identity
2615 }
2616
2617 pub(crate) fn branch(&self) -> &str {
2618 &self.payload.branch
2619 }
2620
2621 pub(crate) fn class(&self) -> RetirementClass {
2622 self.payload.class
2623 }
2624
2625 pub(crate) fn proof(&self) -> Option<&RetirementProof> {
2626 self.payload.proof.as_ref()
2627 }
2628
2629 pub(crate) fn tip(&self) -> &Sha {
2630 &self.payload.tip
2631 }
2632
2633 pub(crate) fn mode(&self) -> Acting {
2634 self.payload.mode
2635 }
2636
2637 pub(crate) fn trigger(&self) -> Trigger {
2638 self.payload.trigger
2639 }
2640
2641 pub(crate) fn at(&self) -> &str {
2642 &self.at
2643 }
2644
2645 fn retirement(&self, base: &str) -> Retirement {
2648 Retirement {
2649 class: self.payload.class,
2650 reason: self.payload.reason,
2651 cause: None,
2652 identity: self.payload.identity.clone(),
2653 branch: self.payload.branch.clone(),
2654 tip: self.payload.tip.clone(),
2655 base: base.to_owned(),
2656 proof: self.payload.proof.clone(),
2657 content_free_commits: Vec::new(),
2658 superseded_by: self.payload.superseded_by.clone(),
2659 differing_paths: self.payload.differing_paths.clone(),
2660 holders: Vec::new(),
2661 }
2662 }
2663}
2664
2665fn stream_token(identity: &str, branch: &str) -> String {
2671 format!(
2672 "branch-{}-{}",
2673 policy::branch_slug(branch),
2674 ids::short_digest(identity)
2675 )
2676}
2677
2678fn open_stream(identity: &str, branch: &str) -> Result<Stream> {
2679 let mut stream = Stream::open(&stream_token(identity, branch))?;
2680 stream.label("identity", identity);
2681 Ok(stream)
2682}
2683
2684struct Host {
2686 registry: Registry,
2687 sessions: Vec<Record>,
2688 streams: Vec<status::Recorded>,
2689 stream_listing: status::Listing,
2691 trailers: Trailers,
2692}
2693
2694impl Host {
2695 fn read() -> Result<Self> {
2696 Self::with(None)
2697 }
2698
2699 fn with(sessions: Option<Vec<Record>>) -> Result<Self> {
2701 let registry = store::load()?;
2702 let (rules, _) = crate::policy::load(®istry)?;
2703 let sessions = match sessions {
2704 Some(listed) => listed,
2705 None => workspace::all()?,
2706 };
2707 let (streams, stream_listing) = status::recorded_streams_whole(&mut Vec::new())?;
2708 Ok(Host {
2709 sessions,
2710 streams,
2711 stream_listing,
2712 trailers: provenance::from_rules(&rules),
2713 registry,
2714 })
2715 }
2716
2717 fn census(&self, identity: &str, reach: Reach) -> Result<Census<'_>> {
2718 let mut census = Census::read(
2719 &self.registry,
2720 identity,
2721 &self.sessions,
2722 &self.streams,
2723 &self.trailers,
2724 reach,
2725 None,
2726 )?;
2727 census.stream_listing = self.stream_listing;
2728 Ok(census)
2729 }
2730
2731 fn identities(&self) -> Vec<String> {
2733 let mut keys: Vec<String> = self
2734 .registry
2735 .checkouts
2736 .values()
2737 .map(|checkout| checkout.identity.clone())
2738 .collect();
2739 keys.sort_unstable();
2740 keys.dedup();
2741 keys
2742 }
2743
2744 fn identity_of(&self, repo: Option<&str>, branch: &str) -> Result<String> {
2747 if !git::is_valid_branch_name(branch) {
2748 return Err(error::invalid(format!(
2749 "{branch:?} is not a valid branch name; `onevcs recoverable --all` lists every \
2750 preserved branch by name"
2751 )));
2752 }
2753 if let Some(repo) = repo {
2754 return Ok(store::resolve(&self.registry, repo)?.key);
2755 }
2756 let mut holding = Vec::new();
2757 for identity in self.identities() {
2758 let census = self.census(&identity, Reach::Offline)?;
2759 let held = !census.copies(branch, &Ask::offline()).copies.is_empty()
2760 || status::retirement_of(&self.streams, &identity, branch).is_some();
2761 if held {
2762 holding.push(identity);
2763 }
2764 }
2765 match holding.len() {
2766 1 => Ok(holding.remove(0)),
2767 0 => Err(Error::UnresolvableReference {
2768 reference: branch.to_owned(),
2769 reason: format!(
2770 "no checkout, pool slot or run clone of a registered identity holds \
2771 {branch:?}, and no retirement of it is recorded; `onevcs recoverable --all` \
2772 lists every preserved branch by name"
2773 ),
2774 }),
2775 _ => Err(error::invalid(format!(
2776 "{branch:?} is held by {count} identities — {named} — so which one is meant \
2777 cannot be decided; name it with `--repo`",
2778 count = holding.len(),
2779 named = holding.join(", "),
2780 ))),
2781 }
2782 }
2783}
2784
2785pub(crate) fn classify(hosting: &dyn Hosting, query: &RetirementQuery) -> Result<Retirement> {
2787 let host = Host::read()?;
2788 let identity = host.identity_of(query.repo.as_deref(), &query.branch)?;
2789 let census = host.census(&identity, Reach::Remote)?;
2790 let ask = Ask::query(hosting);
2791 let classified = census.classify(&query.branch, census.copies(&query.branch, &ask), &ask)?;
2792 if !classified.copies.copies.is_empty() || !classified.copies.unreadable.is_empty() {
2793 return Ok(classified.retirement);
2794 }
2795 match status::retirement_of(&host.streams, &identity, &query.branch) {
2796 Some(record) => Ok(record.retirement(census.base.as_deref().unwrap_or_default())),
2797 None => Err(nowhere(&identity, &query.branch)),
2798 }
2799}
2800
2801fn nowhere(identity: &str, branch: &str) -> Error {
2802 Error::UnresolvableReference {
2803 reference: branch.to_owned(),
2804 reason: format!(
2805 "nothing this host keeps for {identity} holds {branch:?} — no registered checkout, \
2806 pool slot, run clone or the origin — and no retirement of it is recorded"
2807 ),
2808 }
2809}
2810
2811pub(crate) fn classify_offline(census: &Census<'_>, branch: &str) -> Option<Retirement> {
2814 let ask = Ask::offline();
2815 census
2816 .classify(branch, census.copies(branch, &ask), &ask)
2817 .ok()
2818 .map(|classified| classified.retirement)
2819}
2820
2821pub(crate) fn offline_census<'a>(
2824 registry: &'a Registry,
2825 identity: &str,
2826 sessions: &'a [Record],
2827 streams: &'a [status::Recorded],
2828 trailers: &'a Trailers,
2829) -> Result<Census<'a>> {
2830 Census::read(
2831 registry,
2832 identity,
2833 sessions,
2834 streams,
2835 trailers,
2836 Reach::Offline,
2837 None,
2838 )
2839}
2840
2841pub(crate) fn record_has_work(census: &Census<'_>, record: &Record) -> Result<bool> {
2844 let streams = census.streams;
2845 let ask = Ask::offline();
2846 let mut copies = census.copies(&record.branch, &ask);
2847 if copies.copies.is_empty() && copies.unreadable.is_empty() {
2848 let Some(preserved) = status::preserved_for(
2849 streams,
2850 &record.identity,
2851 &record.branch,
2852 Some(&record.token),
2853 ) else {
2854 return Ok(false);
2855 };
2856 copies.copies.push(Copy {
2857 at: Holding::Origin,
2858 tip: preserved.commit,
2859 });
2860 }
2861 let retirement = census.classify(&record.branch, copies, &ask)?.retirement;
2865 Ok(retirement.class != RetirementClass::Retirable
2866 && retirement.reason != Some(KeepReason::IsBase))
2867}
2868
2869pub(crate) fn retire_named(hosting: &dyn Hosting, request: &RetireRequest) -> Result<Retired> {
2871 let host = Host::read()?;
2872 let identity = host.identity_of(request.repo.as_deref(), &request.branch)?;
2873 let census = host.census(&identity, reach_for(request.dry_run))?;
2874 let ask = Ask::acting(Some(hosting), request.dry_run, &[]);
2875 let acting = match request.mode {
2876 RetireMode::Lossless => Acting::Retire,
2877 RetireMode::Reclaim => Acting::Reclaim,
2878 RetireMode::Discard => Acting::Discard,
2879 };
2880 let copies = census.copies(&request.branch, &ask);
2881 if copies.copies.is_empty() && copies.unreadable.is_empty() {
2882 let Some(record) = status::retirement_of(&host.streams, &identity, &request.branch) else {
2883 return Err(nowhere(&identity, &request.branch));
2884 };
2885 let classified = Classified {
2886 retirement: record.retirement(census.base.as_deref().unwrap_or_default()),
2887 copies,
2888 derivation: Derivation::Derived,
2889 };
2890 let plan = census.plan(&request.branch, &classified);
2894 let unfinished = !plan.run_roots.is_empty()
2895 || plan
2896 .sessions
2897 .iter()
2898 .any(|record| record.state == Lifecycle::Open);
2899 return Ok(match (unfinished, request.dry_run) {
2900 (false, _) => Retired::nothing(classified.retirement, RetireOutcome::AlreadyRetired),
2901 (true, true) => Retired {
2902 retirement: classified.retirement,
2903 outcome: RetireOutcome::WouldRetire,
2904 deleted: Vec::new(),
2905 failed: Vec::new(),
2906 slots_returned: Vec::new(),
2907 run_roots_removed: plan.run_roots,
2908 sessions_closed: plan
2909 .sessions
2910 .iter()
2911 .filter(|record| record.state == Lifecycle::Open)
2912 .map(session_token)
2913 .collect(),
2914 derivation: Derivation::Derived,
2915 },
2916 (true, false) => census.finish(
2917 &request.branch,
2918 classified,
2919 plan,
2920 Done {
2921 deleted: Vec::new(),
2922 failed: Vec::new(),
2923 },
2924 (acting, Trigger::Verb),
2925 ),
2926 });
2927 }
2928 act(
2929 &census,
2930 &request.branch,
2931 copies,
2932 acting,
2933 Trigger::Verb,
2934 request.dry_run,
2935 &ask,
2936 )
2937}
2938
2939fn reach_for(dry_run: bool) -> Reach {
2942 match dry_run {
2943 true => Reach::Remote,
2944 false => Reach::Fetch,
2945 }
2946}
2947
2948pub(crate) fn pass(
2950 hosting: Option<&dyn Hosting>,
2951 request: &RetirePass,
2952 trigger: Trigger,
2953) -> Result<RetirementPassReport> {
2954 pass_over(hosting, request, trigger, None)
2955}
2956
2957pub(crate) fn pass_over(
2960 hosting: Option<&dyn Hosting>,
2961 request: &RetirePass,
2962 trigger: Trigger,
2963 sessions: Option<Vec<Record>>,
2964) -> Result<RetirementPassReport> {
2965 for excluded in &request.exclude {
2969 if excluded.identity.trim().is_empty() || !git::is_valid_branch_name(&excluded.branch) {
2970 return Err(error::invalid(format!(
2971 "{branch:?} of {identity:?} is not a branch a pass can leave alone: an \
2972 exclusion names an identity and a valid branch name",
2973 branch = excluded.branch,
2974 identity = excluded.identity,
2975 )));
2976 }
2977 }
2978 let host = Host::with(sessions)?;
2979 let exclude = request
2982 .exclude
2983 .iter()
2984 .map(|excluded| {
2985 let resolved = store::resolve(&host.registry, &excluded.identity).map_err(|why| {
2986 error::invalid(format!(
2987 "{branch:?} of {identity:?} is not a branch a pass can leave alone: the \
2988 exclusion names no registered identity ({why}); `onevcs repos` lists them",
2989 branch = excluded.branch,
2990 identity = excluded.identity,
2991 ))
2992 })?;
2993 Ok(BranchRef {
2994 identity: resolved.key,
2995 branch: excluded.branch.clone(),
2996 })
2997 })
2998 .collect::<Result<Vec<_>>>()?;
2999 let identities = match &request.scope {
3000 Scope::All => host.identities(),
3001 Scope::Repo(repo) => vec![store::resolve(&host.registry, repo)?.key],
3002 };
3003 let verdicts = crate::verdict::Store::open()?;
3004 let mut ask = Ask::acting(hosting, request.dry_run, &exclude);
3005 ask.verdicts = Some(&verdicts);
3006 let mut report = RetirementPassReport {
3007 dry_run: request.dry_run,
3008 examined: Vec::new(),
3009 };
3010 git::memoized(|| -> Result<()> {
3013 for identity in identities {
3014 let census = host.census(&identity, reach_for(request.dry_run))?;
3015 for branch in candidates(&census, &host)? {
3016 let copies = census.copies(&branch, &ask);
3017 if copies.copies.is_empty() && copies.unreadable.is_empty() {
3018 verdicts.forget(&identity, &branch);
3019 continue;
3020 }
3021 report.examined.push(act(
3022 &census,
3023 &branch,
3024 copies,
3025 Acting::Automatic,
3026 trigger,
3027 request.dry_run,
3028 &ask,
3029 )?);
3030 }
3031 }
3032 Ok(())
3033 })?;
3034 verdicts.report();
3035 Ok(report)
3036}
3037
3038fn candidates(census: &Census<'_>, host: &Host) -> Result<Vec<String>> {
3045 let mut named: BTreeSet<String> = host
3046 .sessions
3047 .iter()
3048 .filter(|record| record.identity == census.resolution.key)
3049 .map(|record| record.branch.to_string())
3050 .collect();
3051 named.extend(status::recorded_branches(
3052 &host.streams,
3053 &census.resolution.key,
3054 ));
3055 let mut found: BTreeSet<String> = BTreeSet::new();
3056 for place in &census.places {
3057 if !git::is_repo(&place.repo) {
3058 continue;
3059 }
3060 let Ok(heads) = git::heads(&place.repo) else {
3061 continue;
3062 };
3063 let unpublished: BTreeSet<String> = match heads.iter().all(|(branch, _)| {
3067 named.contains(branch) || census.base.as_deref() == Some(branch.as_str())
3068 }) {
3069 true => BTreeSet::new(),
3070 false => git::unpublished_heads(&place.repo).unwrap_or_default(),
3071 };
3072 for (branch, _) in heads {
3073 if named.contains(&branch) || unpublished.contains(&branch) {
3074 found.insert(branch);
3075 }
3076 }
3077 }
3078 if let Some(base) = &census.base {
3079 found.remove(base);
3080 }
3081 Ok(found.into_iter().collect())
3082}
3083
3084pub(crate) fn retired(record: &Record) -> bool {
3086 status::recorded_streams(&mut Vec::new())
3087 .map(|streams| status::retirement_of(&streams, &record.identity, &record.branch).is_some())
3088 .unwrap_or(false)
3089}
3090
3091pub(crate) fn after_close(record: &Record) {
3095 let retired = (|| -> Result<Option<Retired>> {
3096 let host = Host::read()?;
3097 let recorded = status::recorded_for(
3100 &host.streams,
3101 &record.identity,
3102 &record.branch,
3103 Some(&record.token),
3104 );
3105 if recorded.landing.is_none() {
3106 return Ok(None);
3107 }
3108 let census = host.census(&record.identity, Reach::Fetch)?;
3109 let ask = Ask::acting(None, false, &[]);
3110 let copies = census.copies(&record.branch, &ask);
3111 let classified = census.classify(&record.branch, copies.clone(), &ask)?;
3112 if classified.retirement.class != RetirementClass::Retirable {
3113 return Ok(None);
3114 }
3115 act(
3116 &census,
3117 &record.branch,
3118 copies,
3119 Acting::Automatic,
3120 Trigger::SessionClose,
3121 false,
3122 &ask,
3123 )
3124 .map(Some)
3125 })();
3126 match retired {
3127 Ok(Some(retired)) if retired.outcome == RetireOutcome::Retired => eprintln!(
3128 "onevcs: retired {branch}: {proof}",
3129 branch = record.branch,
3130 proof = retired
3131 .retirement
3132 .proof
3133 .as_ref()
3134 .map(RetirementProof::describe)
3135 .unwrap_or_default(),
3136 ),
3137 Ok(Some(retired)) if retired.outcome == RetireOutcome::Incomplete => eprintln!(
3138 "onevcs: warning: {branch} was retired in part; `onevcs retire {branch} --repo {repo}` \
3139 finishes it: {failed}",
3140 branch = record.branch,
3141 repo = record.publication_checkout.display(),
3142 failed = retired
3143 .failed
3144 .iter()
3145 .map(|failed| format!("{} ({})", failed.location, failed.error))
3146 .collect::<Vec<_>>()
3147 .join("; "),
3148 ),
3149 Ok(_) => {}
3150 Err(failure) => eprintln!(
3151 "onevcs: warning: whether {branch} could be retired was not decided: {failure}",
3152 branch = record.branch,
3153 ),
3154 }
3155}
3156
3157const RECLASSIFICATIONS: usize = 3;
3160
3161#[allow(clippy::too_many_arguments)] fn act(
3165 census: &Census<'_>,
3166 branch: &str,
3167 copies: Copies,
3168 acting: Acting,
3169 trigger: Trigger,
3170 dry_run: bool,
3171 ask: &Ask<'_>,
3172) -> Result<Retired> {
3173 let mut copies = copies;
3174 let mut attempts = 0;
3175 loop {
3176 let classified = census.classify(branch, copies, ask)?;
3177 let derivation = classified.derivation;
3178 if !acting.permits(classified.retirement.class, classified.retirement.reason) {
3179 return Ok(Retired {
3180 derivation,
3181 ..Retired::nothing(classified.retirement, RetireOutcome::Kept)
3182 });
3183 }
3184 let plan = census.plan(branch, &classified);
3185 if dry_run {
3186 return Ok(Retired {
3187 retirement: classified.retirement,
3188 outcome: RetireOutcome::WouldRetire,
3189 deleted: census.holders_of(&classified.copies),
3190 failed: Vec::new(),
3191 slots_returned: plan.slots,
3192 run_roots_removed: plan.run_roots,
3193 sessions_closed: plan.sessions.iter().map(session_token).collect(),
3194 derivation,
3195 });
3196 }
3197 let now = census.copies_now(branch, ask);
3202 let moved = now != classified.copies;
3203 if !moved {
3204 match census.delete(branch, &classified.copies) {
3205 Deletion::Moved => {}
3206 Deletion::Done(done) => {
3207 let finished = census.finish(branch, classified, plan, done, (acting, trigger));
3208 if let (Some(verdicts), RetireOutcome::Retired) =
3209 (ask.verdicts, finished.outcome)
3210 {
3211 verdicts.forget(&census.resolution.key, branch);
3212 }
3213 return Ok(Retired {
3214 derivation,
3215 ..finished
3216 });
3217 }
3218 }
3219 }
3220 attempts += 1;
3221 if attempts >= RECLASSIFICATIONS {
3222 let mut retirement = classified.retirement;
3223 retirement.class = RetirementClass::Keep;
3224 retirement.reason = Some(KeepReason::Unknown);
3225 retirement.cause = Some(UnknownCause::new(
3231 "copy",
3232 branch,
3233 format!(
3234 "a copy of it moved on each of {RECLASSIFICATIONS} reads made immediately \
3235 before deleting it"
3236 ),
3237 ));
3238 retirement.proof = None;
3239 retirement.superseded_by = None;
3240 retirement.differing_paths = Vec::new();
3241 return Ok(Retired::nothing(retirement, RetireOutcome::Kept));
3242 }
3243 copies = census.copies_now(branch, ask);
3244 }
3245}
3246
3247fn session_token(record: &Record) -> SessionToken {
3248 SessionToken(record.token.to_string())
3249}
3250
3251struct Plan {
3254 sessions: Vec<Record>,
3255 run_roots: Vec<PathBuf>,
3256 slots: Vec<PathBuf>,
3257}
3258
3259enum Deletion {
3261 Moved,
3264 Done(Done),
3266}
3267
3268struct Done {
3270 deleted: Vec<BranchHolder>,
3271 failed: Vec<FailedHolder>,
3272}
3273
3274impl Census<'_> {
3275 fn plan(&self, branch: &str, classified: &Classified) -> Plan {
3276 let runs = workspace::identity_dir(&self.resolution.key)
3277 .map(|root| root.join("runs"))
3278 .ok();
3279 let sessions: Vec<Record> = self
3280 .sessions
3281 .iter()
3282 .filter(|record| {
3283 record.identity == self.resolution.key
3284 && *record.branch == *branch
3285 && !record.owner_is_running()
3286 })
3287 .cloned()
3288 .collect();
3289 let run_roots = sessions
3290 .iter()
3291 .filter(|record| {
3292 record.slot.is_none()
3293 && record.run_root.is_dir()
3294 && runs
3295 .as_ref()
3296 .is_some_and(|runs| record.run_root.starts_with(runs))
3297 })
3298 .map(|record| record.run_root.clone())
3299 .collect();
3300 let mut slots: Vec<PathBuf> = Vec::new();
3301 for copy in &classified.copies.copies {
3302 if let Holding::Local(index) = copy.at {
3303 if let Some(slot) = &self.places[index].slot {
3304 if !slots.contains(slot) {
3305 slots.push(slot.clone());
3306 }
3307 }
3308 }
3309 }
3310 for place in &self.places {
3311 let Some(slot) = &place.slot else { continue };
3312 let on_branch = git::checked_out_branch(&slot.join("worktree"))
3313 .is_ok_and(|current| current == branch);
3314 if on_branch && !slots.contains(slot) {
3315 slots.push(slot.clone());
3316 }
3317 }
3318 Plan {
3319 sessions,
3320 run_roots,
3321 slots,
3322 }
3323 }
3324
3325 fn delete(&self, branch: &str, copies: &Copies) -> Deletion {
3328 let mut deleted: Vec<(BranchHolder, Option<(PathBuf, String)>)> = Vec::new();
3329 let mut failed: Vec<FailedHolder> = Vec::new();
3330 let restore = |deleted: &[(BranchHolder, Option<(PathBuf, String)>)]| {
3331 for (_, local) in deleted {
3332 if let Some((repo, tip)) = local {
3333 if let Err(failure) = git::restore_branch(repo, branch, tip) {
3334 eprintln!(
3335 "onevcs: warning: {branch} was deleted from {} and could not be put \
3336 back at {tip} after another copy moved: {failure}",
3337 repo.display()
3338 );
3339 }
3340 }
3341 }
3342 };
3343 for copy in &copies.copies {
3344 let Holding::Local(index) = copy.at else {
3345 continue;
3346 };
3347 let place = &self.places[index];
3348 let holder = self.holder(copy.at);
3349 if let Err(failure) = stand_off(&place.repo, branch) {
3353 failed.push(FailedHolder {
3354 kind: holder.kind,
3355 location: holder.location,
3356 error: failure.to_string(),
3357 });
3358 continue;
3359 }
3360 match git::delete_branch_at(&place.repo, branch, ©.tip) {
3361 Ok(()) => deleted.push((holder, Some((place.repo.clone(), copy.tip.clone())))),
3362 Err(failure) => match git::local_tip(&place.repo, branch) {
3363 LocalTip::At(now) if now.as_str() != copy.tip => {
3364 restore(&deleted);
3365 return Deletion::Moved;
3366 }
3367 LocalTip::Absent => deleted.push((holder, None)),
3368 _ => failed.push(FailedHolder {
3369 kind: holder.kind,
3370 location: holder.location,
3371 error: failure.to_string(),
3372 }),
3373 },
3374 }
3375 }
3376 if let Some(origin) = copies.copies.iter().find(|copy| copy.at == Holding::Origin) {
3377 let holder = self.holder(Holding::Origin);
3378 let answered =
3379 git::delete_remote_branch(self.publication(), "origin", branch, &origin.tip);
3380 let refused = match answered {
3381 Ok(pushed) if pushed.accepted() => None,
3382 Ok(pushed) => Some(
3383 pushed
3384 .refusal()
3385 .map(str::to_owned)
3386 .unwrap_or_else(|| pushed.output().trim().to_owned()),
3387 ),
3388 Err(failure) => Some(failure.to_string()),
3389 };
3390 match refused {
3391 None => deleted.push((holder, None)),
3392 Some(why) => match git::remote_tip(self.publication(), "origin", branch, &[]) {
3393 Ok(RemoteTip::Absent) => deleted.push((holder, None)),
3394 Ok(RemoteTip::At(now)) if now.as_str() != origin.tip => {
3395 restore(&deleted);
3396 return Deletion::Moved;
3397 }
3398 _ => failed.push(FailedHolder {
3399 kind: holder.kind,
3400 location: holder.location,
3401 error: why,
3402 }),
3403 },
3404 }
3405 }
3406 Deletion::Done(Done {
3407 deleted: deleted.into_iter().map(|(holder, _)| holder).collect(),
3408 failed,
3409 })
3410 }
3411
3412 fn finish(
3414 &self,
3415 branch: &str,
3416 classified: Classified,
3417 plan: Plan,
3418 done: Done,
3419 (acting, trigger): (Acting, Trigger),
3420 ) -> Retired {
3421 let Done {
3422 deleted,
3423 mut failed,
3424 } = done;
3425 let mut sessions_closed = Vec::new();
3426 for record in &plan.sessions {
3427 if record.state != Lifecycle::Open {
3428 continue;
3429 }
3430 if let Err(failure) =
3434 workspace::update(&record.token, |stored| stored.state = Lifecycle::Closed)
3435 {
3436 failed.push(FailedHolder {
3437 kind: match record.slot {
3438 Some(_) => BranchHolderKind::Slot,
3439 None => BranchHolderKind::RunClone,
3440 },
3441 location: record.run_root.display().to_string(),
3442 error: format!(
3443 "its session record {} could not be closed: {failure}",
3444 record.token
3445 ),
3446 });
3447 continue;
3448 }
3449 if let Ok(mut stream) = Stream::open(&record.token) {
3450 stream.emit(
3451 EventKind::SessionClosed,
3452 workspace::object(json!({"token": record.token, "branch": record.branch})),
3453 );
3454 }
3455 sessions_closed.push(session_token(record));
3456 }
3457 let mut run_roots_removed = Vec::new();
3458 for run_root in &plan.run_roots {
3459 match remove_run_root(run_root) {
3460 Ok(true) => run_roots_removed.push(run_root.clone()),
3461 Ok(false) => {}
3462 Err(why) => failed.push(FailedHolder {
3463 kind: BranchHolderKind::RunClone,
3464 location: run_root.display().to_string(),
3465 error: why,
3466 }),
3467 }
3468 }
3469 let mut slots_returned = Vec::new();
3470 for slot in &plan.slots {
3471 match self.return_slot(slot) {
3472 Ok(true) => slots_returned.push(slot.clone()),
3473 Ok(false) => {}
3474 Err(why) => failed.push(FailedHolder {
3475 kind: BranchHolderKind::Slot,
3476 location: slot.display().to_string(),
3477 error: why,
3478 }),
3479 }
3480 }
3481 let outcome = match failed.is_empty() {
3482 true => RetireOutcome::Retired,
3483 false => RetireOutcome::Incomplete,
3484 };
3485 let retired = Retired {
3486 retirement: classified.retirement,
3487 outcome,
3488 deleted,
3489 failed,
3490 slots_returned,
3491 run_roots_removed,
3492 sessions_closed,
3493 derivation: Derivation::Derived,
3494 };
3495 record_retirement(&retired, branch, acting, trigger);
3496 retired
3497 }
3498
3499 fn return_slot(&self, slot: &Path) -> std::result::Result<bool, String> {
3504 let pool = slot
3505 .parent()
3506 .ok_or_else(|| format!("{} has no pool above it", slot.display()))?;
3507 let _serial =
3508 lock::exclusive(&pool::placement_identity(pool)).map_err(|e| e.to_string())?;
3509 if !pool::is_idle(&self.resolution.key, slot).map_err(|e| e.to_string())? {
3510 return Ok(false);
3511 }
3512 let Some(_inside) =
3513 lock::try_exclusive(&workspace::occupancy_identity(slot)).map_err(|e| e.to_string())?
3514 else {
3515 return Err("a command is working in it right now".to_owned());
3516 };
3517 let clone = slot.join("clone");
3518 let worktree = slot.join("worktree");
3519 let _ = git::fetch(&clone, "origin");
3524 let base = Ref::from_git(self.base.clone().unwrap_or_else(|| "HEAD".to_owned()));
3525 let delete = workspaces::resolve_for(&self.resolution, workspaces::Overrides::default())
3526 .map(|resolved| resolved.delete)
3527 .map_err(|e| e.to_string())?;
3528 workspace::reset_onto_base(
3529 &worktree,
3530 &workspace::integrated_base(&clone, &base),
3531 &delete,
3532 )
3533 .map_err(|e| e.to_string())?;
3534 Ok(true)
3535 }
3536}
3537
3538fn stand_off(repo: &Path, branch: &str) -> Result<()> {
3541 for (worktree, on) in git::worktree_heads(repo)? {
3542 if on.as_deref() == Some(branch) {
3543 git::detach_head(&worktree)?;
3544 }
3545 }
3546 Ok(())
3547}
3548
3549fn emptied(path: &Path) -> bool {
3553 std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
3554}
3555
3556fn remove_run_root(run_root: &Path) -> std::result::Result<bool, String> {
3559 if !processes::holding(run_root).is_empty() {
3560 return Err("a process is working inside it".to_owned());
3561 }
3562 let Some(_exclusive) =
3563 lock::try_exclusive(&workspace::occupancy_identity(run_root)).map_err(|e| e.to_string())?
3564 else {
3565 return Err("a command is working in it right now".to_owned());
3566 };
3567 let clone = run_root.join("clone");
3568 if git::is_repo(&clone)
3569 && !git::unpublished_branches(&clone)
3570 .unwrap_or_default()
3571 .is_empty()
3572 {
3573 return Ok(false);
3574 }
3575 std::fs::remove_dir_all(run_root)
3576 .map(|()| true)
3577 .map_err(|e| format!("it could not be removed: {e}"))
3578}
3579
3580fn record_retirement(retired: &Retired, branch: &str, acting: Acting, trigger: Trigger) {
3582 let retirement = &retired.retirement;
3583 let payload = RetiredPayload {
3584 identity: retirement.identity.clone(),
3585 branch: branch.to_owned(),
3586 tip: retirement.tip.clone(),
3587 class: retirement.class,
3588 reason: retirement.reason,
3589 proof: retirement.proof.clone(),
3590 superseded_by: retirement.superseded_by.clone(),
3591 differing_paths: retirement.differing_paths.clone(),
3592 mode: acting.to_owned(),
3593 trigger: trigger.to_owned(),
3594 deleted: retired.deleted.clone(),
3595 failed: retired.failed.clone(),
3596 slots_returned: retired.slots_returned.clone(),
3597 run_roots_removed: retired.run_roots_removed.clone(),
3598 sessions_closed: retired
3599 .sessions_closed
3600 .iter()
3601 .map(|token| token.0.clone())
3602 .collect(),
3603 };
3604 let Ok(Value::Object(payload)) = serde_json::to_value(&payload) else {
3605 return;
3606 };
3607 match open_stream(&retirement.identity, branch) {
3608 Ok(mut stream) => {
3609 let result = if retired.outcome == RetireOutcome::Incomplete {
3610 crate::event::EmissionResult::Failed
3611 } else {
3612 crate::event::EmissionResult::Success
3613 };
3614 stream.emit_result(EventKind::BranchRetired, payload, Vec::new(), result);
3615 }
3616 Err(failure) => eprintln!(
3617 "onevcs: warning: {branch} was retired and the record of it was not written: {failure}"
3618 ),
3619 }
3620}
3621
3622pub(crate) fn supersede(record: &Supersession) -> Result<()> {
3624 let registry = store::load()?;
3625 let identity = store::resolve(®istry, &record.repo)?.key;
3626 for (name, value) in [("branch", &record.branch), ("--by", &record.superseded_by)] {
3627 if !git::is_valid_branch_name(value) {
3628 return Err(error::invalid(format!(
3629 "{value:?} is not a valid branch name, so it cannot be the {name} of a supersession"
3630 )));
3631 }
3632 }
3633 if record.branch == record.superseded_by {
3634 return Err(error::invalid(format!(
3635 "{branch:?} cannot supersede itself",
3636 branch = record.branch
3637 )));
3638 }
3639 let landing = SupersedingLanding::parse(&record.landing).ok_or_else(|| {
3640 error::invalid(format!(
3641 "{landing:?} is neither a full commit id nor a change request's http(s) URL, so it \
3642 names no landing",
3643 landing = record.landing
3644 ))
3645 })?;
3646 label::validate(&record.labels)?;
3647 let streams = status::recorded_streams(&mut Vec::new())?;
3648 let already = status::supersessions(&streams, &identity, &record.branch)
3649 .into_iter()
3650 .any(|recorded| {
3651 *recorded.superseded_by == *record.superseded_by && recorded.landing == landing
3652 });
3653 if already {
3654 return Ok(());
3655 }
3656 let mut stream = open_stream(&identity, &record.branch)?;
3657 stream.emit(
3658 EventKind::BranchSuperseded,
3659 workspace::object(json!({
3660 "identity": identity,
3661 "branch": record.branch,
3662 "superseded_by": record.superseded_by,
3663 "landing": landing.to_string(),
3664 "labels": record.labels,
3665 })),
3666 );
3667 Ok(())
3668}
3669
3670#[cfg(all(test, unix))]
3687mod unknown_causes {
3688 use std::os::unix::fs::PermissionsExt;
3689 use std::path::{Path, PathBuf};
3690 use std::process::Command;
3691
3692 use serde_json::json;
3693
3694 use super::{Ask, Census, Copies, Host, Reach, UnknownCause};
3695 use crate::event::EventKind;
3696 use crate::ops::{publish_branch, register_checkout, BranchPublishRequest};
3697 use crate::stream::Stream;
3698 use crate::workspace::Record;
3699 use crate::{lock, Lifecycle, Providers, SessionRequest};
3700
3701 const BRANCH: &str = "feature/done";
3702
3703 fn git(cwd: &Path, args: &[&str]) -> String {
3704 let output = Command::new("git")
3705 .args(args)
3706 .current_dir(cwd)
3707 .output()
3708 .expect("git runs");
3709 assert!(
3710 output.status.success(),
3711 "git {} failed in {}: {}",
3712 args.join(" "),
3713 cwd.display(),
3714 String::from_utf8_lossy(&output.stderr)
3715 );
3716 String::from_utf8_lossy(&output.stdout).trim().to_owned()
3717 }
3718
3719 fn commit(worktree: &Path, file: &str) {
3720 std::fs::write(worktree.join(file), format!("{file}\n")).expect("a file to commit");
3721 git(worktree, &["add", "-A"]);
3722 git(
3723 worktree,
3724 &["commit", "-q", "-m", &format!("feat: write {file}")],
3725 );
3726 }
3727
3728 fn opened(providers: &Providers<'_>) -> crate::Session {
3729 providers
3730 .vcs
3731 .open_session(SessionRequest {
3732 repo: "project".to_owned(),
3733 branch: Some(BRANCH.to_owned()),
3734 branch_name: None,
3735 branch_prefix: None,
3736 base: None,
3737 execution_checkout: None,
3738 pool: None,
3739 overflow: None,
3740 labels: Default::default(),
3741 refuse_conflicts: false,
3742 })
3743 .expect("a session over the branch")
3744 }
3745
3746 struct Landed {
3748 _root: tempfile::TempDir,
3750 stale: Record,
3752 }
3753
3754 impl Landed {
3755 fn new() -> Self {
3756 let root = tempfile::tempdir().expect("a scratch host");
3757 let at = root.path().canonicalize().expect("a canonical root");
3758 std::fs::write(
3762 at.join(".gitconfig"),
3763 "[user]\n\tname = Unit\n\temail = unit@example.invalid\n[init]\n\t\
3764 defaultBranch = main\n[commit]\n\tgpgsign = false\n[maintenance]\n\t\
3765 auto = false\n",
3766 )
3767 .expect("a git configuration");
3768 std::env::set_var("HOME", &at);
3769 std::env::set_var(crate::home::HOME_ENV, at.join(".onevcs"));
3770 let seed = at.join("seed");
3771 std::fs::create_dir_all(&seed).expect("a seed");
3772 git(&seed, &["init", "-q", "-b", "main"]);
3773 commit(&seed, "README.md");
3774 let origin = at.join("project.git");
3775 git(&at, &["init", "-q", "--bare", &origin.to_string_lossy()]);
3776 git(&seed, &["push", "-q", &origin.to_string_lossy(), "main"]);
3777 let checkout = at.join("project");
3778 git(
3779 &at,
3780 &[
3781 "clone",
3782 "-q",
3783 &origin.to_string_lossy(),
3784 &checkout.to_string_lossy(),
3785 ],
3786 );
3787 let hosted = url::Url::parse("https://gitlab.com/acme/project.git").expect("a URL");
3790 register_checkout(&checkout, Some(&hosted)).expect("the checkout registers");
3791 std::fs::write(
3792 at.join(".onevcs/rules.yml"),
3793 "version: 1\nrules: []\ndefault: {publication: local-direct, approvals: none}\n",
3794 )
3795 .expect("a rules file");
3796 let providers = Providers::real();
3797 let worked = opened(&providers);
3798 commit(&worked.worktree, "done.txt");
3799 crate::close_session(&providers, &worked.token).expect("the session closes");
3800 publish_branch(
3801 &providers,
3802 &BranchPublishRequest {
3803 repo: checkout.clone(),
3804 branch: BRANCH.to_owned(),
3805 title: None,
3806 body: None,
3807 policy: None,
3808 term_scope: Default::default(),
3809 },
3810 )
3811 .expect("the branch lands");
3812 let left = opened(&providers);
3813 let mut stale = crate::workspace::all()
3814 .expect("the session records")
3815 .into_iter()
3816 .find(|record| record.token.to_string() == left.token.0)
3817 .expect("the open session's record");
3818 assert_eq!(stale.state, Lifecycle::Open);
3819 stale.owner_started = None;
3820 Landed { _root: root, stale }
3821 }
3822
3823 fn host(&self) -> Host {
3825 let records = crate::workspace::all()
3826 .expect("the session records")
3827 .into_iter()
3828 .map(|record| match record.token == self.stale.token {
3829 true => self.stale.clone(),
3830 false => record,
3831 })
3832 .collect();
3833 Host::with(Some(records)).expect("the host reads")
3834 }
3835
3836 fn identity(&self) -> String {
3837 self.stale.identity.clone()
3838 }
3839 }
3840
3841 fn closed<T>(path: &Path, read: impl FnOnce() -> T) -> T {
3843 let original = std::fs::metadata(path).expect("a path").permissions();
3844 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000))
3845 .expect("the path is closed");
3846 assert!(
3847 std::fs::File::open(path).is_err(),
3848 "the premise: this suite runs as a user the mode binds"
3849 );
3850 let answer = read();
3851 std::fs::set_permissions(path, original).expect("the path is open again");
3852 answer
3853 }
3854
3855 fn census<'h>(host: &'h Host, identity: &str) -> Census<'h> {
3856 host.census(identity, Reach::Offline)
3857 .expect("the identity is read")
3858 }
3859
3860 fn says(cause: &UnknownCause, operation: &str, subject: &Path) {
3861 assert_eq!(cause.operation, operation, "{cause}");
3862 assert_eq!(cause.subject, subject.display().to_string(), "{cause}");
3863 assert!(
3864 !cause.error.trim().is_empty(),
3865 "a cause with no error: {cause}"
3866 );
3867 }
3868
3869 fn classified(census: &Census<'_>, copies: Copies) -> UnknownCause {
3871 let retirement = census
3872 .classify(BRANCH, copies, &Ask::offline())
3873 .expect("a classification")
3874 .retirement;
3875 retirement
3876 .cause()
3877 .cloned()
3878 .unwrap_or_else(|| panic!("kept without a cause: {retirement:?}"))
3879 }
3880
3881 #[test]
3882 fn a_place_whose_branches_cannot_be_read_is_a_copy_cause_naming_it() {
3883 let landed = Landed::new();
3884 let host = landed.host();
3885 let identity = landed.identity();
3886 let clone = landed.stale.clone.clone();
3887 let (copies, cause) = closed(&clone, || {
3888 let census = census(&host, &identity);
3889 let copies = census.copies(BRANCH, &Ask::offline());
3890 let cause = classified(&census, copies.clone());
3891 (copies, cause)
3892 });
3893 says(&copies.unreadable[0], "copy", &clone);
3894 assert_eq!(cause, copies.unreadable[0]);
3895 }
3896
3897 #[test]
3898 fn a_directory_of_run_roots_that_cannot_be_listed_is_a_census_cause_naming_it() {
3899 let landed = Landed::new();
3900 let host = landed.host();
3901 let identity = landed.identity();
3902 let runs = landed
3903 .stale
3904 .run_root
3905 .parent()
3906 .expect("the run roots")
3907 .to_path_buf();
3908 let (unlisted, cause) = closed(&runs, || {
3909 let census = census(&host, &identity);
3910 let copies = census.copies(BRANCH, &Ask::offline());
3911 (census.unlisted.clone(), classified(&census, copies))
3912 });
3913 says(&unlisted[0], "census", &runs);
3914 assert_eq!(cause, unlisted[0]);
3915 }
3916
3917 #[test]
3918 fn a_lease_that_cannot_be_read_is_a_live_holder_cause_naming_the_run_root() {
3919 let landed = Landed::new();
3920 let host = landed.host();
3921 let identity = landed.identity();
3922 let lease = lock::path_for(&landed.stale.lease()).expect("the lease's path");
3923 assert!(
3924 lease.is_file(),
3925 "the premise: opening the session took its lease"
3926 );
3927 let read = closed(&lease, || census(&host, &identity).live_holder(BRANCH));
3928 says(
3929 &read.expect_err("a lease nobody can open answers no holder"),
3930 "live-holder",
3931 &landed.stale.run_root,
3932 );
3933 }
3934
3935 #[test]
3936 fn a_worktree_that_cannot_be_read_is_a_worked_in_cause_naming_it() {
3937 let landed = Landed::new();
3938 let host = landed.host();
3939 let identity = landed.identity();
3940 let census = census(&host, &identity);
3941 let copies = census.copies(BRANCH, &Ask::offline());
3942 assert!(copies.unreadable.is_empty(), "{copies:?}");
3943 let worktree = landed.stale.worktree.clone();
3944 let read = closed(&worktree, || census.worked_in(BRANCH, &copies));
3945 says(
3946 &read.expect_err("a worktree nobody can read answers no state"),
3947 "worked-in",
3948 &worktree,
3949 );
3950 }
3951
3952 #[test]
3953 fn a_copy_whose_commit_is_missing_is_a_judge_cause_naming_where_it_is() {
3954 let landed = Landed::new();
3955 let clone = landed.stale.clone.clone();
3956 let refs = PathBuf::from(git(&clone, &["rev-parse", "--absolute-git-dir"]))
3957 .join("refs/heads")
3958 .join(BRANCH);
3959 std::fs::write(&refs, "1111111111111111111111111111111111111111\n")
3960 .expect("the clone's copy names a commit nobody has");
3961 let host = landed.host();
3962 let identity = landed.identity();
3963 let census = census(&host, &identity);
3964 let copies = census.copies(BRANCH, &Ask::offline());
3965 let base_tip = census.base_tip.clone().expect("the base is read");
3966 let read = census.judged(
3967 BRANCH,
3968 &base_tip,
3969 &copies,
3970 &census.evidence(BRANCH),
3971 &Ask::offline(),
3972 );
3973 let Err(cause) = read else {
3974 panic!("a commit nobody has was judged");
3975 };
3976 says(&cause, "judge", &clone);
3977 assert_eq!(classified(&census, copies).operation, "judge");
3978 }
3979
3980 #[test]
3981 fn a_change_request_the_host_cannot_be_asked_about_is_a_host_consult_cause_naming_it() {
3982 let landed = Landed::new();
3983 let url = "https://gitlab.com/acme/project/-/merge_requests/7";
3988 let mut stream = Stream::open(&landed.stale.token.to_string()).expect("its stream");
3989 stream.emit(
3990 EventKind::ChangeOpened,
3991 json!({"url": url, "host": "github", "id": "7", "base": "main"})
3992 .as_object()
3993 .expect("an object")
3994 .clone(),
3995 );
3996 drop(stream);
3997 let host = landed.host();
3998 let identity = landed.identity();
3999 let census = census(&host, &identity);
4000 let providers = Providers::real();
4001 let ask = Ask::acting(Some(providers.hosting), true, &[]);
4002 let copies = census.copies(BRANCH, &Ask::offline());
4003 let (_, decided) = census.consult(
4004 BRANCH,
4005 &mut census.evidence(BRANCH),
4006 &copies.first_tip(),
4007 None,
4008 &ask,
4009 None,
4010 );
4011 let cause = decided.expect_err("a host nobody can ask decides nothing");
4012 assert_eq!(
4013 (cause.operation.as_str(), cause.subject.as_str()),
4014 ("host-consult", url)
4015 );
4016 assert!(!cause.error.trim().is_empty(), "{cause}");
4017 }
4018}
4019
4020#[cfg(test)]
4025mod permits {
4026 use super::{Acting, KeepReason, RetirementClass};
4027
4028 const KEPT: [KeepReason; 9] = [
4029 KeepReason::HeldByLiveSession,
4030 KeepReason::Excluded,
4031 KeepReason::OpenChangeRequest,
4032 KeepReason::ClosedChangeRequest,
4033 KeepReason::CheckedOut,
4034 KeepReason::DirtyWorktree,
4035 KeepReason::UnmergedUniqueCommits,
4036 KeepReason::Unknown,
4037 KeepReason::IsBase,
4038 ];
4039
4040 #[test]
4041 fn a_discard_takes_a_kept_branch_only_for_work_nothing_landed() {
4042 for reason in KEPT {
4043 assert_eq!(
4044 Acting::Discard.permits(RetirementClass::Keep, Some(reason)),
4045 reason == KeepReason::UnmergedUniqueCommits,
4046 "{}",
4047 reason.as_str()
4048 );
4049 }
4050 assert!(Acting::Discard.permits(RetirementClass::Retirable, None));
4051 assert!(Acting::Discard.permits(RetirementClass::SupersededWithChanges, None));
4052 }
4053
4054 #[test]
4055 fn no_other_mode_takes_a_kept_branch_for_any_reason() {
4056 for acting in [Acting::Retire, Acting::Reclaim, Acting::Automatic] {
4057 for reason in KEPT {
4058 assert!(
4059 !acting.permits(RetirementClass::Keep, Some(reason)),
4060 "{} took {}",
4061 acting.as_str(),
4062 reason.as_str()
4063 );
4064 }
4065 }
4066 assert!(Acting::Reclaim.permits(RetirementClass::SupersededWithChanges, None));
4067 assert!(!Acting::Retire.permits(RetirementClass::SupersededWithChanges, None));
4068 assert!(!Acting::Automatic.permits(RetirementClass::SupersededWithChanges, None));
4069 }
4070}