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