1use std::io::Write;
20use std::sync::Arc;
21use std::time::Duration;
22
23use bytes::Bytes;
24use time::OffsetDateTime;
25use tracing::{info, warn};
26use uuid::Uuid;
27
28use super::snapshot::{
29 BundleEntry, MalformedBundleKey, RefSnapshot, RepoSnapshot, analyze_objects,
30};
31use super::{ManageError, Prompter, StaleReason};
32#[cfg(test)]
40use super::DEFAULT_LOCK_TTL_SECONDS;
41use crate::keys;
42use crate::object_store::{ObjectMeta, ObjectStore, ObjectStoreError, PutOpts};
43use crate::packchain::audit::{self, AuditReport, BranchRow};
44use crate::protocol::push::lock_ttl_from_env_seconds;
45use crate::url::StorageEngine;
46
47#[derive(Debug, Clone, Copy)]
49pub struct DoctorOpts {
50 pub delete_bundle: bool,
54 pub lock_ttl_seconds: Option<u64>,
67 pub delete_stale_locks: bool,
71 pub engine: StorageEngine,
76}
77
78impl Default for DoctorOpts {
79 fn default() -> Self {
80 Self {
81 delete_bundle: false,
82 lock_ttl_seconds: None,
83 delete_stale_locks: false,
84 engine: StorageEngine::Bundle,
85 }
86 }
87}
88
89pub struct Doctor<'a> {
91 store: Arc<dyn ObjectStore>,
92 prefix: String,
93 opts: DoctorOpts,
94 prompter: &'a dyn Prompter,
95}
96
97impl<'a> Doctor<'a> {
98 #[must_use]
102 pub fn new(
103 store: Arc<dyn ObjectStore>,
104 prefix: impl Into<String>,
105 opts: DoctorOpts,
106 prompter: &'a dyn Prompter,
107 ) -> Self {
108 Self {
109 store,
110 prefix: prefix.into(),
111 opts,
112 prompter,
113 }
114 }
115
116 fn resolved_lock_ttl_seconds(&self) -> u64 {
128 self.opts
129 .lock_ttl_seconds
130 .unwrap_or_else(lock_ttl_from_env_seconds)
131 }
132
133 pub async fn run(&self) -> Result<(), ManageError> {
149 self.run_into(&mut std::io::stdout()).await
150 }
151
152 pub(crate) async fn run_into<W: Write>(&self, out: &mut W) -> Result<(), ManageError> {
166 let list_prefix = keys::join(Some(&self.prefix), "");
171 let objects = self.store.list(&list_prefix).await?;
172 let mut snapshot = analyze_objects(&objects, &list_prefix, &self.store).await?;
173 write!(out, "{}", self.report(&snapshot))?;
174
175 if !snapshot.malformed_bundle_keys.is_empty() {
182 write!(
183 out,
184 "{}",
185 render_malformed_bundles_section(&snapshot.malformed_bundle_keys),
186 )?;
187 }
188
189 if let Some(section) = self.maybe_render_packchain_section(&objects).await? {
194 write!(out, "{section}")?;
195 }
196
197 let dup_refs: Vec<String> = snapshot
200 .refs
201 .iter()
202 .filter(|(_, r)| r.bundles.len() > 1)
203 .map(|(name, _)| name.clone())
204 .collect();
205 for ref_path in dup_refs {
206 self.fix_multiple_bundles(out, &mut snapshot, &ref_path)
207 .await?;
208 }
209
210 if !snapshot.is_head_valid() {
211 self.fix_head(out, &mut snapshot).await?;
212 }
213
214 self.list_and_handle_stale_locks(out, &objects).await?;
215 Ok(())
216 }
217
218 pub(crate) async fn maybe_render_packchain_section(
230 &self,
231 objects: &[ObjectMeta],
232 ) -> Result<Option<String>, ManageError> {
233 if !matches!(self.opts.engine, StorageEngine::Packchain) {
234 return Ok(None);
235 }
236 let report = audit::audit(&*self.store, &self.prefix, objects).await?;
237 Ok(Some(render_packchain_section(&report)))
238 }
239
240 #[must_use]
244 pub(crate) fn report(&self, snapshot: &RepoSnapshot) -> String {
245 use std::fmt::Write;
246 let mut out = String::new();
247 let _ = writeln!(out, "{}:", self.report_label());
248 for (ref_path, r) in &snapshot.refs {
249 let star = if r.is_protected { "*" } else { "" };
250 let status = match r.bundles.len() {
251 0 if r.has_chain => "Ok",
252 0 => "No bundles",
253 1 => "Ok",
254 _ => "Multiple bundles",
255 };
256 let _ = writeln!(out, " {star} {ref_path}: {status}");
257 }
258 let head_label = snapshot
264 .head
265 .as_deref()
266 .filter(|h| {
267 snapshot
268 .refs
269 .get(*h)
270 .is_some_and(RefSnapshot::has_branch_data)
271 })
272 .unwrap_or("Invalid");
273 let _ = writeln!(out, " HEAD: {head_label}");
274 out
275 }
276
277 fn report_label(&self) -> &str {
281 if self.prefix.is_empty() {
282 "(root)"
283 } else {
284 &self.prefix
285 }
286 }
287
288 async fn fix_multiple_bundles<W: Write>(
289 &self,
290 out: &mut W,
291 snapshot: &mut RepoSnapshot,
292 ref_path: &str,
293 ) -> Result<(), ManageError> {
294 writeln!(
295 out,
296 "\nFix multiple bundles for repo {} and ref {ref_path}",
297 self.report_label()
298 )?;
299
300 let ref_entry = snapshot.refs.get_mut(ref_path).ok_or_else(|| {
304 ManageError::Internal(format!(
305 "fix_multiple_bundles called with ref {ref_path} absent from snapshot"
306 ))
307 })?;
308
309 let labels: Vec<String> = ref_entry
310 .bundles
311 .iter()
312 .map(|b| format!("{} {}", b.sha, b.last_modified))
313 .collect();
314
315 let keep_idx = self.prompter.select("Choose the bundle to keep", &labels)?;
316 let keeper = ref_entry.bundles.get(keep_idx).ok_or_else(|| {
321 ManageError::Internal(format!(
322 "prompter returned out-of-range index {keep_idx} for {} bundle(s)",
323 ref_entry.bundles.len()
324 ))
325 })?;
326 let keeper_sha = keeper.sha.clone();
327 let keeper_key = keeper.key.clone();
328
329 if !self.prompter.confirm("Confirm and apply changes")? {
330 writeln!(out, "Aborted")?;
334 return Ok(());
335 }
336
337 match self.store.head(&keeper_key).await {
352 Ok(_) => {}
353 Err(ObjectStoreError::NotFound(_)) => {
354 writeln!(
355 out,
356 "Selected keeper bundle {keeper_sha} for {ref_path} is no longer \
357 present on the bucket; refusing to evict losers. Re-run doctor."
358 )?;
359 warn!(
360 ref_path,
361 keeper = %keeper_sha,
362 key = %keeper_key,
363 "doctor fix_multiple_bundles: keeper disappeared between snapshot and eviction",
364 );
365 return Err(ManageError::StaleSnapshot {
366 entity: ref_path.to_owned(),
367 reason: StaleReason::Deleted,
368 });
369 }
370 Err(e) => return Err(e.into()),
371 }
372
373 writeln!(out, "Keeping {keeper_sha}")?;
374 let bundles = std::mem::take(&mut ref_entry.bundles);
378 let (keepers, losers): (Vec<_>, Vec<_>) =
379 bundles.into_iter().partition(|b| b.sha == keeper_sha);
380 ref_entry.bundles = keepers;
381
382 for losing in &losers {
383 self.evict_losing_bundle(out, ref_path, losing).await?;
384 }
385 Ok(())
386 }
387
388 async fn evict_losing_bundle<W: Write>(
389 &self,
390 out: &mut W,
391 ref_path: &str,
392 losing: &BundleEntry,
393 ) -> Result<(), ManageError> {
394 if self.opts.delete_bundle {
410 writeln!(out, "Removing {}", losing.sha)?;
411 } else {
412 let mut buf = [0u8; uuid::fmt::Simple::LENGTH];
417 let suffix = &Uuid::new_v4().simple().encode_lower(&mut buf)[..8];
418 let new_ref = format!("{ref_path}_{suffix}");
419 let dst_key = keys::bundle_key(Some(&self.prefix), &new_ref, &losing.sha);
424 writeln!(out, "Moving {} to new branch {new_ref}", losing.sha)?;
425 match self.store.copy(&losing.key, &dst_key).await {
426 Ok(()) => {}
427 Err(ObjectStoreError::NotFound(_)) => {
428 writeln!(
429 out,
430 "Skipping {}: loser bundle already gone before quarantine copy",
431 losing.sha,
432 )?;
433 return Ok(());
434 }
435 Err(e) => return Err(e.into()),
436 }
437 }
438 match self.store.delete(&losing.key).await {
439 Ok(()) => Ok(()),
440 Err(ObjectStoreError::NotFound(_)) => {
441 writeln!(
442 out,
443 "Skipping {}: loser bundle already gone before delete",
444 losing.sha,
445 )?;
446 Ok(())
447 }
448 Err(e) => Err(e.into()),
449 }
450 }
451
452 async fn fix_head<W: Write>(
453 &self,
454 out: &mut W,
455 snapshot: &mut RepoSnapshot,
456 ) -> Result<(), ManageError> {
457 writeln!(out, "\nFix invalid HEAD for repo {}", self.report_label())?;
458
459 let candidates: Vec<&str> = snapshot
464 .refs
465 .iter()
466 .filter(|(k, r)| k.starts_with("refs/heads/") && r.has_branch_data())
467 .map(|(k, _)| k.as_str())
468 .collect();
469 if candidates.is_empty() {
470 writeln!(
471 out,
472 "No `refs/heads/*` available to assign as HEAD; skipping."
473 )?;
474 return Ok(());
475 }
476
477 let labels: Vec<String> = candidates
478 .iter()
479 .map(|k| short_branch_name(k).to_owned())
480 .collect();
481 let chosen = self
482 .prompter
483 .select("Choose the new HEAD branch", &labels)?;
484 let new_head = candidates
489 .get(chosen)
490 .copied()
491 .ok_or_else(|| {
492 ManageError::Internal(format!(
493 "prompter returned out-of-range index {chosen} for {} HEAD candidate(s)",
494 candidates.len()
495 ))
496 })?
497 .to_owned();
498
499 let branch_prefix = keys::ref_listing_prefix(Some(&self.prefix), &new_head);
528 let recheck = self.store.list(&branch_prefix).await?;
529 if !super::has_branch_data(&recheck) {
530 let residue_only = !recheck.is_empty();
535 if residue_only {
536 writeln!(
537 out,
538 "Selected branch {new_head} is considered gone — only operational \
539 metadata (lock files / PROTECTED# marker) remains under its prefix. \
540 Refusing to write stale HEAD. Re-run doctor."
541 )?;
542 } else {
543 writeln!(
544 out,
545 "Selected branch {new_head} was deleted between selection and HEAD write; \
546 refusing to write stale HEAD. Re-run doctor."
547 )?;
548 }
549 warn!(
550 branch = %new_head,
551 residue_only,
552 "doctor fix_head: chosen branch disappeared between snapshot and HEAD write"
553 );
554 let reason = if residue_only {
555 StaleReason::ResidueOnly
556 } else {
557 StaleReason::Deleted
558 };
559 return Err(ManageError::StaleSnapshot {
560 entity: new_head,
561 reason,
562 });
563 }
564
565 let head_key = keys::join(Some(&self.prefix), "HEAD");
566 writeln!(out, "Setting {new_head} as HEAD")?;
567 self.store
568 .put_bytes(&head_key, Bytes::from(new_head.clone()), PutOpts::default())
569 .await?;
570 snapshot.head = Some(new_head);
571 Ok(())
572 }
573
574 async fn list_and_handle_stale_locks<W: Write>(
575 &self,
576 out: &mut W,
577 objects: &[ObjectMeta],
578 ) -> Result<(), ManageError> {
579 writeln!(out, "\nScanning for stale locks...")?;
580 let ttl = Duration::from_secs(self.resolved_lock_ttl_seconds());
581 let stale = scan_stale_locks(objects, ttl);
582
583 if stale.is_empty() {
584 writeln!(out, "No stale locks found.")?;
585 return Ok(());
586 }
587
588 writeln!(out, "Found stale locks:")?;
589 for (key, age) in &stale {
590 writeln!(out, " - {key} (age: {}s)", age.as_secs())?;
591 }
592
593 if !self.opts.delete_stale_locks {
594 writeln!(
595 out,
596 "\nRun with --delete-stale-locks to remove them automatically."
597 )?;
598 return Ok(());
599 }
600
601 writeln!(out, "\nDeleting stale locks...")?;
602 let mut deleted = 0usize;
603 let mut skipped = 0usize;
604 for (key, _) in &stale {
605 match delete_stale_lock_if_still_stale(self.store.as_ref(), key, ttl, out).await? {
606 DeleteOutcome::Deleted => deleted += 1,
607 DeleteOutcome::SkippedNotStale
608 | DeleteOutcome::SkippedDisappeared
609 | DeleteOutcome::SkippedHeadError => skipped += 1,
610 DeleteOutcome::DeleteFailed => {}
611 }
612 }
613 if skipped > 0 {
614 writeln!(
615 out,
616 "Skipped {skipped} lock(s) that became fresh or disappeared since listing; deleted {deleted}."
617 )?;
618 }
619 Ok(())
620 }
621}
622
623fn scan_stale_locks(objects: &[ObjectMeta], ttl: Duration) -> Vec<(&str, Duration)> {
629 scan_stale_locks_at(OffsetDateTime::now_utc(), objects, ttl)
630}
631
632fn scan_stale_locks_at(
638 now: OffsetDateTime,
639 objects: &[ObjectMeta],
640 ttl: Duration,
641) -> Vec<(&str, Duration)> {
642 objects
643 .iter()
644 .filter(|o| super::is_lock_key(&o.key))
645 .filter_map(|o| {
646 let raw_age = now - o.last_modified;
647 if raw_age.is_negative() {
648 warn!(
649 key = %o.key,
650 skew_secs = -raw_age.whole_seconds(),
651 "lock's last_modified is in the future; treating as \
652 out-of-tolerance (clock skew?)",
653 );
654 return ttl.is_zero().then_some((o.key.as_str(), Duration::ZERO));
663 }
664 let age = Duration::try_from(raw_age)
669 .expect("raw_age is non-negative — branch above returned for negatives");
670 (age > ttl).then_some((o.key.as_str(), age))
671 })
672 .collect()
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680enum DeleteOutcome {
681 Deleted,
683 SkippedNotStale,
686 SkippedDisappeared,
689 SkippedHeadError,
693 DeleteFailed,
697}
698
699async fn delete_stale_lock_if_still_stale<W: Write>(
710 store: &dyn ObjectStore,
711 key: &str,
712 ttl: Duration,
713 out: &mut W,
714) -> Result<DeleteOutcome, ManageError> {
715 match store.head(key).await {
716 Ok(meta) => {
717 let raw_age = OffsetDateTime::now_utc() - meta.last_modified;
718 let still_stale = if raw_age.is_negative() {
719 warn!(
720 key,
721 skew_secs = -raw_age.whole_seconds(),
722 "lock's last_modified is in the future on re-check; \
723 treating as out-of-tolerance (clock skew?)",
724 );
725 ttl.is_zero()
730 } else {
731 let age = Duration::try_from(raw_age)
732 .expect("raw_age is non-negative — branch above returned for negatives");
733 age > ttl
734 };
735 if !still_stale {
736 writeln!(
737 out,
738 "Skipping {key}: lock no longer stale, refusing to delete"
739 )?;
740 warn!(key, "lock no longer stale, skipping doctor delete");
741 return Ok(DeleteOutcome::SkippedNotStale);
742 }
743 }
744 Err(ObjectStoreError::NotFound(_)) => {
745 writeln!(out, "Skipping {key}: lock disappeared concurrently")?;
746 warn!(key, "lock disappeared between listing and delete, skipping");
747 return Ok(DeleteOutcome::SkippedDisappeared);
748 }
749 Err(e) => {
750 writeln!(out, "Failed to re-check {key}: {e}")?;
753 warn!(key, error = %e, "head re-check failed; skipping delete");
754 return Ok(DeleteOutcome::SkippedHeadError);
755 }
756 }
757 match store.delete(key).await {
758 Ok(()) => {
759 writeln!(out, "Deleted {key}")?;
760 info!(key, "deleted stale lock");
761 Ok(DeleteOutcome::Deleted)
762 }
763 Err(e) => {
764 writeln!(out, "Failed to delete {key}: {e}")?;
766 Ok(DeleteOutcome::DeleteFailed)
767 }
768 }
769}
770
771fn short_branch_name(full: &str) -> &str {
782 full.rsplit('/').next().unwrap_or(full)
783}
784
785fn render_malformed_bundles_section(entries: &[MalformedBundleKey]) -> String {
793 use std::fmt::Write;
794 let mut out = String::new();
795 let _ = writeln!(
796 out,
797 "\nMalformed bundle keys (push silently ignores these):"
798 );
799 for entry in entries {
800 let _ = writeln!(out, " - {} (ref {})", entry.key, entry.ref_path);
801 }
802 let _ = writeln!(
803 out,
804 " Delete each key manually (`aws s3 rm` / `az storage blob delete`) and re-push the ref.",
805 );
806 out
807}
808
809fn render_packchain_section(report: &AuditReport) -> String {
814 use std::fmt::Write;
815 let mut out = String::new();
816 let _ = writeln!(out, "\n=== Packchain ===");
817 let _ = writeln!(
818 out,
819 "Orphans: {} pack(s), {}",
820 report.orphans.pack_count,
821 format_bytes(report.orphans.bytes),
822 );
823
824 if report.tombstones.is_empty() {
825 let _ = writeln!(out, "Tombstones (pending sweep): none");
826 } else {
827 let _ = writeln!(out, "Tombstones (pending sweep):");
828 for t in &report.tombstones {
829 let age = format_age(t.age_hours);
830 let _ = writeln!(
831 out,
832 " - run id {}, marked {} ({}), {} pack(s)",
833 t.run_id, t.marked_at, age, t.orphan_count,
834 );
835 }
836 }
837
838 let candidates: Vec<&BranchRow> = report
839 .branches
840 .iter()
841 .filter(|r| r.recommend_compact)
842 .collect();
843 if candidates.is_empty() {
844 let _ = writeln!(out, "Branches needing compaction: none");
845 } else {
846 let _ = writeln!(out, "Branches needing compaction:");
847 for r in candidates {
848 let _ = writeln!(
849 out,
850 " - {}: {} segment(s), {} since full_at [recommend compact]",
851 r.ref_path,
852 r.segments_total,
853 format_bytes(r.bytes_total),
854 );
855 }
856 }
857
858 let has_corrupt = report.branches.iter().any(|r| !r.has_full_at_segment);
859 if report.dangling.is_empty() && !has_corrupt {
860 let _ = writeln!(out, "ERRORS: none");
861 } else {
862 let _ = writeln!(out, "ERRORS:");
863 for d in &report.dangling {
864 let _ = writeln!(
865 out,
866 " - {}/chain.json references missing pack {}",
867 d.ref_path, d.missing_pack_key,
868 );
869 }
870 for b in report.branches.iter().filter(|r| !r.has_full_at_segment) {
871 let _ = writeln!(
872 out,
873 " - {}/chain.json full_at not present in segments (corrupt manifest)",
874 b.ref_path,
875 );
876 }
877 }
878 out
879}
880
881fn format_bytes(bytes: u64) -> String {
886 const KIB: u64 = 1_024;
887 const MIB: u64 = 1_024 * KIB;
888 const GIB: u64 = 1_024 * MIB;
889 #[allow(clippy::cast_precision_loss)]
892 let scaled = |unit: u64| bytes as f64 / unit as f64;
893 if bytes >= GIB {
894 format!("{:.1} GiB", scaled(GIB))
895 } else if bytes >= MIB {
896 format!("{:.1} MiB", scaled(MIB))
897 } else if bytes >= KIB {
898 format!("{:.1} KiB", scaled(KIB))
899 } else {
900 format!("{bytes} B")
901 }
902}
903
904fn format_age(hours: i64) -> String {
908 if hours <= 0 {
909 "<1h".to_owned()
910 } else if hours < 48 {
911 format!("{hours}h ago")
912 } else {
913 format!("{}d ago", hours / 24)
914 }
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920 use crate::manage::snapshot::analyze;
921 use crate::manage::{ScriptedPrompter, scripted::Answer};
922 use crate::object_store::mock::{Fault, MockStore};
923 use bytes::Bytes;
924 use time::OffsetDateTime;
925
926 fn store_arc(mock: &MockStore) -> Arc<dyn ObjectStore> {
927 Arc::new(mock.clone())
928 }
929
930 const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
935 const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
936 const SHA_C: &str = "cccccccccccccccccccccccccccccccccccccccc";
937
938 #[tokio::test]
939 async fn no_issues_round_trip_runs_clean() {
940 let mock = MockStore::new();
941 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
942 mock.insert(
943 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
944 Bytes::from("b"),
945 );
946 let initial_keys = mock.keys();
947 let prompter = ScriptedPrompter::new([]);
948 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
949 doctor
950 .run_into(&mut std::io::sink())
951 .await
952 .expect("doctor.run");
953 assert_eq!(mock.keys(), initial_keys);
957 assert_eq!(prompter.remaining(), 0);
958 }
959
960 #[tokio::test]
961 async fn fix_multiple_bundles_quarantines_losers_by_default() {
962 let mock = MockStore::new();
963 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
964 mock.insert(
965 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
966 Bytes::from("body-a"),
967 );
968 mock.insert(
969 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
970 Bytes::from("body-b"),
971 );
972 let prompter = ScriptedPrompter::new([Answer::Select(0), Answer::Confirm(true)]);
973 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
974 doctor
975 .run_into(&mut std::io::sink())
976 .await
977 .expect("doctor.run");
978
979 assert!(mock.contains(&format!("myrepo/refs/heads/main/{SHA_A}.bundle")));
981 assert!(!mock.contains(&format!("myrepo/refs/heads/main/{SHA_B}.bundle")));
983 let loser_tail = format!("/{SHA_B}.bundle");
987 let moved = mock
988 .keys()
989 .into_iter()
990 .find(|k| k.starts_with("myrepo/refs/heads/main_") && k.ends_with(&loser_tail))
991 .expect("quarantine key created");
992 let suffix = moved
993 .strip_prefix("myrepo/refs/heads/main_")
994 .and_then(|rest| rest.strip_suffix(&loser_tail))
995 .expect("quarantine key matches `<ref>_<suffix>/<sha>.bundle`");
996 assert_eq!(suffix.len(), 8, "expected 8-char suffix, got {suffix:?}");
997 assert!(
998 suffix
999 .chars()
1000 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
1001 "expected lowercase hex suffix, got {suffix:?}"
1002 );
1003 }
1004
1005 #[tokio::test]
1006 async fn fix_multiple_bundles_delete_mode_removes_losers() {
1007 let mock = MockStore::new();
1008 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1009 mock.insert(
1010 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1011 Bytes::from("a"),
1012 );
1013 mock.insert(
1014 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
1015 Bytes::from("b"),
1016 );
1017 let prompter = ScriptedPrompter::new([Answer::Select(1), Answer::Confirm(true)]);
1018 let opts = DoctorOpts {
1019 delete_bundle: true,
1020 ..DoctorOpts::default()
1021 };
1022 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1023 doctor
1024 .run_into(&mut std::io::sink())
1025 .await
1026 .expect("doctor.run");
1027 assert!(!mock.contains(&format!("myrepo/refs/heads/main/{SHA_A}.bundle")));
1028 assert!(mock.contains(&format!("myrepo/refs/heads/main/{SHA_B}.bundle")));
1029 }
1030
1031 #[tokio::test]
1032 async fn fix_multiple_bundles_user_aborts_keeps_originals() {
1033 let mock = MockStore::new();
1034 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1035 mock.insert(
1036 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1037 Bytes::from("a"),
1038 );
1039 mock.insert(
1040 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
1041 Bytes::from("b"),
1042 );
1043 let prompter = ScriptedPrompter::new([Answer::Select(0), Answer::Confirm(false)]);
1044 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1045 doctor
1049 .run_into(&mut std::io::sink())
1050 .await
1051 .expect("user-no should not error");
1052 assert!(mock.contains(&format!("myrepo/refs/heads/main/{SHA_A}.bundle")));
1053 assert!(mock.contains(&format!("myrepo/refs/heads/main/{SHA_B}.bundle")));
1054 }
1055
1056 #[tokio::test]
1057 async fn fix_multiple_bundles_out_of_range_select_returns_internal_error() {
1058 let mock = MockStore::new();
1063 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1064 mock.insert(
1065 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1066 Bytes::from("a"),
1067 );
1068 mock.insert(
1069 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
1070 Bytes::from("b"),
1071 );
1072 let prompter = ScriptedPrompter::new([Answer::Select(99)]);
1074 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1075 let err = doctor
1076 .run_into(&mut std::io::sink())
1077 .await
1078 .expect_err("out-of-range index propagates");
1079 assert!(
1080 matches!(err, ManageError::Internal(ref msg) if msg.contains("out-of-range")),
1081 "expected ManageError::Internal, got {err:?}",
1082 );
1083 }
1084
1085 struct DeleteOnConfirmPrompter {
1092 select_index: usize,
1093 confirm_answer: bool,
1094 mock: MockStore,
1095 keys_to_delete: Vec<String>,
1096 fired: std::sync::Mutex<bool>,
1097 }
1098
1099 impl Prompter for DeleteOnConfirmPrompter {
1100 fn select(&self, _prompt: &str, _options: &[String]) -> Result<usize, ManageError> {
1101 Ok(self.select_index)
1102 }
1103
1104 fn confirm(&self, _prompt: &str) -> Result<bool, ManageError> {
1105 let mut fired = self.fired.lock().expect("fired mutex poisoned");
1106 if !*fired {
1107 for key in &self.keys_to_delete {
1108 let _ = self.mock.remove_key(key);
1109 }
1110 *fired = true;
1111 }
1112 Ok(self.confirm_answer)
1113 }
1114 }
1115
1116 #[tokio::test]
1117 async fn fix_multiple_bundles_refuses_when_keeper_disappears_after_confirm() {
1118 let mock = MockStore::new();
1127 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1128 mock.insert(
1129 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1130 Bytes::from("a"),
1131 );
1132 mock.insert(
1133 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
1134 Bytes::from("b"),
1135 );
1136 let initial_keys = mock.keys();
1137 let prompter = DeleteOnConfirmPrompter {
1141 select_index: 0,
1142 confirm_answer: true,
1143 mock: mock.clone(),
1144 keys_to_delete: vec![format!("myrepo/refs/heads/main/{SHA_A}.bundle")],
1145 fired: std::sync::Mutex::new(false),
1146 };
1147 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1148 let mut out = Vec::new();
1149 let err = doctor
1150 .run_into(&mut out)
1151 .await
1152 .expect_err("missing keeper must surface as stale snapshot");
1153 assert!(
1154 matches!(
1155 &err,
1156 ManageError::StaleSnapshot { entity, reason: StaleReason::Deleted }
1157 if entity == "refs/heads/main"
1158 ),
1159 "expected ManageError::StaleSnapshot {{ refs/heads/main, Deleted }}, got {err:?}",
1160 );
1161 let rendered = err.to_string();
1164 assert!(
1165 rendered.contains("refs/heads/main")
1166 && rendered.contains("was deleted between selection and write")
1167 && rendered.contains("re-run doctor"),
1168 "expected Deleted-flavoured Display, got: {rendered}",
1169 );
1170
1171 assert!(
1178 mock.contains(&format!("myrepo/refs/heads/main/{SHA_B}.bundle")),
1179 "loser must NOT be evicted when keeper recheck fails (bucket: {:?})",
1180 mock.keys(),
1181 );
1182 let mut expected = initial_keys.clone();
1186 expected.retain(|k| k != &format!("myrepo/refs/heads/main/{SHA_A}.bundle"));
1187 let mut actual = mock.keys();
1188 expected.sort();
1189 actual.sort();
1190 assert_eq!(
1191 actual, expected,
1192 "doctor mutated bucket beyond the simulated concurrent delete",
1193 );
1194
1195 let output = String::from_utf8(out).expect("doctor output is utf-8");
1198 assert!(
1199 output.contains(SHA_A),
1200 "expected keeper SHA in operator output:\n{output}",
1201 );
1202 assert!(
1203 output.contains("no longer present"),
1204 "expected 'no longer present' message:\n{output}",
1205 );
1206 assert!(
1207 output.contains("Re-run doctor"),
1208 "expected re-run instruction:\n{output}",
1209 );
1210 assert!(
1213 !output.contains(&format!("Keeping {SHA_A}")),
1214 "doctor printed keeper announcement despite aborting:\n{output}",
1215 );
1216 assert!(
1218 !output.contains(&format!("Moving {SHA_B}")),
1219 "doctor printed eviction line despite aborting:\n{output}",
1220 );
1221 }
1222
1223 #[tokio::test]
1224 async fn fix_multiple_bundles_proceeds_when_keeper_still_present_after_confirm() {
1225 let mock = MockStore::new();
1231 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1232 mock.insert(
1233 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1234 Bytes::from("a"),
1235 );
1236 mock.insert(
1237 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
1238 Bytes::from("b"),
1239 );
1240 mock.insert(
1241 format!("myrepo/refs/heads/main/{SHA_C}.bundle"),
1242 Bytes::from("c"),
1243 );
1244 let prompter = DeleteOnConfirmPrompter {
1249 select_index: 0,
1250 confirm_answer: true,
1251 mock: mock.clone(),
1252 keys_to_delete: vec![format!("myrepo/refs/heads/main/{SHA_B}.bundle")],
1253 fired: std::sync::Mutex::new(false),
1254 };
1255 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1256 doctor
1257 .run_into(&mut std::io::sink())
1258 .await
1259 .expect("unrelated concurrent delete must not block keeper-survival path");
1260
1261 assert!(mock.contains(&format!("myrepo/refs/heads/main/{SHA_A}.bundle")));
1263 assert!(!mock.contains(&format!("myrepo/refs/heads/main/{SHA_B}.bundle")));
1265 assert!(
1269 !mock.contains(&format!("myrepo/refs/heads/main/{SHA_C}.bundle")),
1270 "loser SHA_C must be evicted from the ref after a successful keeper recheck",
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn fix_multiple_bundles_propagates_transient_head_failure_on_keeper() {
1276 let mock = MockStore::new();
1283 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1284 mock.insert(
1285 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1286 Bytes::from("a"),
1287 );
1288 mock.insert(
1289 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
1290 Bytes::from("b"),
1291 );
1292 let initial_keys = mock.keys();
1293 let keeper_key = format!("myrepo/refs/heads/main/{SHA_A}.bundle");
1294 mock.arm(Fault::NetworkOnHead {
1295 key: keeper_key.clone(),
1296 });
1297 let prompter = ScriptedPrompter::new([Answer::Select(0), Answer::Confirm(true)]);
1298 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1299 let err = doctor
1300 .run_into(&mut std::io::sink())
1301 .await
1302 .expect_err("transient HEAD failure must propagate, not silently succeed");
1303 assert!(
1304 matches!(err, ManageError::Store(_)),
1305 "expected ManageError::Store from the head-fault, got {err:?}",
1306 );
1307 let mut actual = mock.keys();
1309 let mut expected = initial_keys.clone();
1310 actual.sort();
1311 expected.sort();
1312 assert_eq!(
1313 actual, expected,
1314 "doctor mutated bucket despite inconclusive keeper recheck",
1315 );
1316 }
1317
1318 #[tokio::test]
1319 async fn fix_head_out_of_range_select_returns_internal_error() {
1320 let mock = MockStore::new();
1327 mock.insert(
1329 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1330 Bytes::from("b"),
1331 );
1332 mock.insert(
1333 format!("myrepo/refs/heads/dev/{SHA_C}.bundle"),
1334 Bytes::from("c"),
1335 );
1336 let prompter = ScriptedPrompter::new([Answer::Select(42)]);
1340 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1341 let err = doctor
1342 .run_into(&mut std::io::sink())
1343 .await
1344 .expect_err("out-of-range HEAD index propagates");
1345 assert!(
1346 matches!(err, ManageError::Internal(ref msg) if msg.contains("HEAD candidate")),
1347 "expected ManageError::Internal naming HEAD candidate, got {err:?}",
1348 );
1349 }
1350
1351 #[tokio::test]
1352 async fn fix_head_writes_chosen_branch() {
1353 let mock = MockStore::new();
1354 mock.insert(
1355 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1356 Bytes::from("b"),
1357 );
1358 mock.insert(
1359 format!("myrepo/refs/heads/dev/{SHA_C}.bundle"),
1360 Bytes::from("c"),
1361 );
1362 let prompter = ScriptedPrompter::new([Answer::Select(1)]);
1366 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1367 doctor
1368 .run_into(&mut std::io::sink())
1369 .await
1370 .expect("doctor.run");
1371
1372 let head_bytes = mock.get_bytes("myrepo/HEAD").await.expect("HEAD written");
1373 assert_eq!(&head_bytes[..], b"refs/heads/main");
1374 }
1375
1376 struct DeleteBeforeReturnPrompter {
1382 index: usize,
1383 mock: MockStore,
1384 keys_to_delete: Vec<String>,
1385 keys_to_insert: Vec<String>,
1390 fired: std::sync::Mutex<bool>,
1391 }
1392
1393 impl Prompter for DeleteBeforeReturnPrompter {
1394 fn select(&self, _prompt: &str, _options: &[String]) -> Result<usize, ManageError> {
1395 let mut fired = self.fired.lock().expect("fired mutex poisoned");
1396 if !*fired {
1397 for key in &self.keys_to_delete {
1398 let _ = self.mock.remove_key(key);
1399 }
1400 for key in &self.keys_to_insert {
1401 self.mock.insert(key.clone(), Bytes::new());
1402 }
1403 *fired = true;
1404 }
1405 Ok(self.index)
1406 }
1407
1408 fn confirm(&self, _prompt: &str) -> Result<bool, ManageError> {
1409 panic!("DeleteBeforeReturnPrompter does not expect confirm prompts")
1410 }
1411 }
1412
1413 #[tokio::test]
1414 async fn fix_head_refuses_when_chosen_branch_deleted_between_select_and_write() {
1415 let mock = MockStore::new();
1424 mock.insert(
1425 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1426 Bytes::from("b"),
1427 );
1428 mock.insert(
1429 format!("myrepo/refs/heads/dev/{SHA_C}.bundle"),
1430 Bytes::from("c"),
1431 );
1432 let prompter = DeleteBeforeReturnPrompter {
1434 index: 1,
1435 mock: mock.clone(),
1436 keys_to_delete: vec![format!("myrepo/refs/heads/main/{SHA_A}.bundle")],
1437 keys_to_insert: vec![],
1438 fired: std::sync::Mutex::new(false),
1439 };
1440 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1441 let mut out = Vec::new();
1442 let err = doctor
1443 .run_into(&mut out)
1444 .await
1445 .expect_err("stale snapshot must surface as an error, not silent success");
1446 assert!(
1447 matches!(
1448 &err,
1449 ManageError::StaleSnapshot { entity, reason: StaleReason::Deleted }
1450 if entity == "refs/heads/main"
1451 ),
1452 "expected ManageError::StaleSnapshot {{ refs/heads/main, Deleted }}, got {err:?}",
1453 );
1454 let rendered = err.to_string();
1457 assert!(
1458 rendered.contains("refs/heads/main")
1459 && rendered.contains("was deleted between selection and write")
1460 && rendered.contains("re-run doctor"),
1461 "expected Deleted-flavoured Display, got: {rendered}",
1462 );
1463
1464 assert!(
1467 !mock.contains("myrepo/HEAD"),
1468 "HEAD was written despite chosen branch being deleted",
1469 );
1470
1471 let output = String::from_utf8(out).expect("doctor output is utf-8");
1474 assert!(
1475 output.contains("refs/heads/main")
1476 && output.contains("deleted between selection and HEAD write")
1477 && output.contains("Re-run doctor"),
1478 "expected race-detection message, got:\n{output}",
1479 );
1480 assert!(
1483 !output.contains("Setting refs/heads/main as HEAD"),
1484 "doctor printed the HEAD-write confirmation despite aborting:\n{output}",
1485 );
1486 }
1487
1488 #[tokio::test]
1489 async fn fix_head_succeeds_when_unrelated_branch_deleted_during_prompt() {
1490 let mock = MockStore::new();
1496 mock.insert(
1497 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1498 Bytes::from("b"),
1499 );
1500 mock.insert(
1501 format!("myrepo/refs/heads/dev/{SHA_C}.bundle"),
1502 Bytes::from("c"),
1503 );
1504 let prompter = DeleteBeforeReturnPrompter {
1508 index: 1,
1509 mock: mock.clone(),
1510 keys_to_delete: vec![format!("myrepo/refs/heads/dev/{SHA_C}.bundle")],
1511 keys_to_insert: vec![],
1512 fired: std::sync::Mutex::new(false),
1513 };
1514 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1515 doctor
1516 .run_into(&mut std::io::sink())
1517 .await
1518 .expect("unrelated concurrent delete must not block HEAD write");
1519
1520 let head_bytes = mock.get_bytes("myrepo/HEAD").await.expect("HEAD written");
1521 assert_eq!(&head_bytes[..], b"refs/heads/main");
1522 }
1523
1524 #[tokio::test]
1525 async fn fix_head_refuses_when_chosen_branch_left_with_only_lock_or_marker() {
1526 let mock = MockStore::new();
1540 mock.insert(
1541 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1542 Bytes::from("b"),
1543 );
1544 mock.insert(
1545 format!("myrepo/refs/heads/dev/{SHA_C}.bundle"),
1546 Bytes::from("c"),
1547 );
1548 let prompter = DeleteBeforeReturnPrompter {
1550 index: 1,
1551 mock: mock.clone(),
1552 keys_to_delete: vec![format!("myrepo/refs/heads/main/{SHA_A}.bundle")],
1553 keys_to_insert: vec![
1554 "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
1555 format!("myrepo/refs/heads/main/{}", keys::PROTECTED_MARKER_SEGMENT),
1556 ],
1557 fired: std::sync::Mutex::new(false),
1558 };
1559 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1560 let mut out = Vec::new();
1561 let err = doctor
1562 .run_into(&mut out)
1563 .await
1564 .expect_err("residue-only branch must surface as stale snapshot");
1565 assert!(
1566 matches!(
1567 &err,
1568 ManageError::StaleSnapshot { entity, reason: StaleReason::ResidueOnly }
1569 if entity == "refs/heads/main"
1570 ),
1571 "expected ManageError::StaleSnapshot {{ refs/heads/main, ResidueOnly }}, \
1572 got {err:?}",
1573 );
1574 let rendered = err.to_string();
1578 assert!(
1579 rendered.contains("refs/heads/main")
1580 && rendered.contains("only operational metadata")
1581 && rendered.contains("PROTECTED# marker")
1582 && rendered.contains("re-run doctor"),
1583 "expected ResidueOnly-flavoured Display, got: {rendered}",
1584 );
1585 assert!(
1586 !rendered.contains("was deleted between selection and write"),
1587 "ResidueOnly Display must not use the Deleted wording: {rendered}",
1588 );
1589
1590 assert!(
1593 !mock.contains("myrepo/HEAD"),
1594 "HEAD was written despite chosen branch having only operational metadata",
1595 );
1596
1597 let output = String::from_utf8(out).expect("doctor output is utf-8");
1600 assert!(
1601 output.contains("refs/heads/main"),
1602 "expected branch name in output:\n{output}",
1603 );
1604 assert!(
1605 output.contains("considered gone"),
1606 "expected 'considered gone' framing in output:\n{output}",
1607 );
1608 assert!(
1609 output.contains("operational metadata"),
1610 "expected residue rationale in output:\n{output}",
1611 );
1612 assert!(
1613 !output.contains("Setting refs/heads/main as HEAD"),
1614 "doctor printed the HEAD-write confirmation despite aborting:\n{output}",
1615 );
1616 }
1617
1618 struct ArmFaultPrompter {
1626 mock: MockStore,
1627 branch_prefix: String,
1628 fired: std::sync::Mutex<bool>,
1629 }
1630
1631 impl Prompter for ArmFaultPrompter {
1632 fn select(&self, _prompt: &str, _options: &[String]) -> Result<usize, ManageError> {
1633 let mut fired = self.fired.lock().expect("fired mutex poisoned");
1634 if !*fired {
1635 self.mock.arm(Fault::AccessDeniedOnList {
1636 prefix: self.branch_prefix.clone(),
1637 });
1638 *fired = true;
1639 }
1640 Ok(0)
1641 }
1642 fn confirm(&self, _prompt: &str) -> Result<bool, ManageError> {
1643 panic!("ArmFaultPrompter does not expect confirm prompts")
1644 }
1645 }
1646
1647 #[tokio::test]
1648 async fn fix_head_propagates_recheck_list_failure_without_writing_head() {
1649 let mock = MockStore::new();
1659 mock.insert(
1660 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1661 Bytes::from("b"),
1662 );
1663 let prompter = ArmFaultPrompter {
1669 mock: mock.clone(),
1670 branch_prefix: "myrepo/refs/heads/main/".to_owned(),
1671 fired: std::sync::Mutex::new(false),
1672 };
1673 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1674 let err = doctor
1675 .run_into(&mut std::io::sink())
1676 .await
1677 .expect_err("list-fault on the re-check must propagate, not silently succeed");
1678 assert!(
1683 matches!(err, ManageError::Store(_)),
1684 "expected ManageError::Store from the list-fault, got {err:?}",
1685 );
1686 assert!(
1691 !mock.contains("myrepo/HEAD"),
1692 "HEAD was written despite the re-check list failing",
1693 );
1694 }
1695
1696 #[tokio::test]
1697 async fn stale_lock_listed_but_not_deleted_by_default() {
1698 let mock = MockStore::new();
1699 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1700 mock.insert(
1701 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1702 Bytes::from("b"),
1703 );
1704 let stale = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1705 mock.insert_with(
1706 "myrepo/refs/heads/main/LOCK#.lock",
1707 Bytes::new(),
1708 stale,
1709 PutOpts::default(),
1710 );
1711 let prompter = ScriptedPrompter::new([]);
1712 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
1713 doctor
1714 .run_into(&mut std::io::sink())
1715 .await
1716 .expect("doctor.run");
1717 assert!(
1718 mock.contains("myrepo/refs/heads/main/LOCK#.lock"),
1719 "lock retained without --delete-stale-locks"
1720 );
1721 }
1722
1723 #[tokio::test]
1724 async fn stale_lock_deleted_when_flag_set() {
1725 let mock = MockStore::new();
1726 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1727 mock.insert(
1728 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1729 Bytes::from("b"),
1730 );
1731 let stale = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1732 mock.insert_with(
1733 "myrepo/refs/heads/main/LOCK#.lock",
1734 Bytes::new(),
1735 stale,
1736 PutOpts::default(),
1737 );
1738 let opts = DoctorOpts {
1739 delete_stale_locks: true,
1740 ..DoctorOpts::default()
1741 };
1742 let prompter = ScriptedPrompter::new([]);
1743 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1744 doctor
1745 .run_into(&mut std::io::sink())
1746 .await
1747 .expect("doctor.run");
1748 assert!(!mock.contains("myrepo/refs/heads/main/LOCK#.lock"));
1749 }
1750
1751 #[tokio::test]
1752 async fn fresh_lock_is_not_flagged_stale() {
1753 let mock = MockStore::new();
1754 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
1755 mock.insert(
1756 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
1757 Bytes::from("b"),
1758 );
1759 mock.insert("myrepo/refs/heads/main/LOCK#.lock", Bytes::new());
1761 let opts = DoctorOpts {
1762 delete_stale_locks: true,
1763 ..DoctorOpts::default()
1764 };
1765 let prompter = ScriptedPrompter::new([]);
1766 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1767 doctor
1768 .run_into(&mut std::io::sink())
1769 .await
1770 .expect("doctor.run");
1771 assert!(mock.contains("myrepo/refs/heads/main/LOCK#.lock"));
1772 }
1773
1774 #[tokio::test]
1787 async fn stale_listing_with_fresh_head_skips_delete() {
1788 let mock = MockStore::new();
1789 mock.insert("myrepo/refs/heads/main/LOCK#.lock", Bytes::new());
1793
1794 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1799 let synthetic_listing = vec![ObjectMeta {
1800 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
1801 size: 0,
1802 last_modified: stale_ts,
1803 etag: None,
1804 }];
1805
1806 let opts = DoctorOpts {
1807 delete_stale_locks: true,
1808 ..DoctorOpts::default()
1809 };
1810 let prompter = ScriptedPrompter::new([]);
1811 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1812 let mut out = Vec::new();
1813 doctor
1814 .list_and_handle_stale_locks(&mut out, &synthetic_listing)
1815 .await
1816 .expect("stale-lock handler");
1817 let captured = String::from_utf8(out).expect("utf-8 output");
1818
1819 assert!(
1820 mock.contains("myrepo/refs/heads/main/LOCK#.lock"),
1821 "fresh lock at stale-listed key must not be deleted",
1822 );
1823 assert!(
1824 captured.contains("no longer stale"),
1825 "skip reason missing from operator output: {captured:?}",
1826 );
1827 assert!(
1828 captured.contains("Skipped 1 lock(s)"),
1829 "skipped-count summary missing: {captured:?}",
1830 );
1831 }
1832
1833 #[tokio::test]
1836 async fn stale_listing_with_stale_head_deletes() {
1837 let mock = MockStore::new();
1838 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1839 mock.insert_with(
1840 "myrepo/refs/heads/main/LOCK#.lock",
1841 Bytes::new(),
1842 stale_ts,
1843 PutOpts::default(),
1844 );
1845 let synthetic_listing = vec![ObjectMeta {
1846 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
1847 size: 0,
1848 last_modified: stale_ts,
1849 etag: None,
1850 }];
1851
1852 let opts = DoctorOpts {
1853 delete_stale_locks: true,
1854 ..DoctorOpts::default()
1855 };
1856 let prompter = ScriptedPrompter::new([]);
1857 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1858 let mut out = Vec::new();
1859 doctor
1860 .list_and_handle_stale_locks(&mut out, &synthetic_listing)
1861 .await
1862 .expect("stale-lock handler");
1863 let captured = String::from_utf8(out).expect("utf-8 output");
1864
1865 assert!(
1866 !mock.contains("myrepo/refs/heads/main/LOCK#.lock"),
1867 "stale lock must be deleted when HEAD confirms staleness",
1868 );
1869 assert!(
1870 captured.contains("Deleted myrepo/refs/heads/main/LOCK#.lock"),
1871 "delete confirmation missing: {captured:?}",
1872 );
1873 }
1874
1875 #[tokio::test]
1879 async fn stale_listing_with_vanished_head_skips_without_error() {
1880 let mock = MockStore::new();
1881 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1883 let synthetic_listing = vec![ObjectMeta {
1884 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
1885 size: 0,
1886 last_modified: stale_ts,
1887 etag: None,
1888 }];
1889
1890 let opts = DoctorOpts {
1891 delete_stale_locks: true,
1892 ..DoctorOpts::default()
1893 };
1894 let prompter = ScriptedPrompter::new([]);
1895 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1896 let mut out = Vec::new();
1897 doctor
1898 .list_and_handle_stale_locks(&mut out, &synthetic_listing)
1899 .await
1900 .expect("vanished lock must not error");
1901 let captured = String::from_utf8(out).expect("utf-8 output");
1902
1903 assert!(
1904 captured.contains("disappeared concurrently"),
1905 "concurrent-cleanup skip reason missing: {captured:?}",
1906 );
1907 assert!(
1908 !captured.contains("Deleted myrepo/refs/heads/main/LOCK#.lock"),
1909 "must not log a delete for a vanished key: {captured:?}",
1910 );
1911 }
1912
1913 #[tokio::test]
1921 async fn stale_listing_with_transient_head_error_skips_lock_without_aborting() {
1922 let mock = MockStore::new();
1923 let key = "myrepo/refs/heads/main/LOCK#.lock";
1924 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1929 mock.insert_with(key, Bytes::new(), stale_ts, PutOpts::default());
1930 mock.arm(Fault::NetworkOnHead {
1931 key: key.to_owned(),
1932 });
1933
1934 let synthetic_listing = vec![ObjectMeta {
1935 key: key.to_owned(),
1936 size: 0,
1937 last_modified: stale_ts,
1938 etag: None,
1939 }];
1940
1941 let opts = DoctorOpts {
1942 delete_stale_locks: true,
1943 ..DoctorOpts::default()
1944 };
1945 let prompter = ScriptedPrompter::new([]);
1946 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
1947 let mut out = Vec::new();
1948 doctor
1949 .list_and_handle_stale_locks(&mut out, &synthetic_listing)
1950 .await
1951 .expect("transient HEAD error must not abort the sweep");
1952 let captured = String::from_utf8(out).expect("utf-8 output");
1953
1954 assert!(
1955 mock.contains(key),
1956 "lock must survive a transient HEAD failure (best-effort sweep)",
1957 );
1958 assert!(
1959 captured.contains("Failed to re-check"),
1960 "operator output missing re-check failure line: {captured:?}",
1961 );
1962 assert!(
1963 !captured.contains("Deleted myrepo/refs/heads/main/LOCK#.lock"),
1964 "must not log a delete when HEAD was inconclusive: {captured:?}",
1965 );
1966 assert!(
1967 captured.contains("Skipped 1 lock(s)"),
1968 "skipped-count summary missing: {captured:?}",
1969 );
1970 }
1971
1972 #[tokio::test]
1978 async fn stale_listing_with_delete_failure_logs_and_continues() {
1979 let mock = MockStore::new();
1980 let main_key = "myrepo/refs/heads/main/LOCK#.lock";
1981 let dev_key = "myrepo/refs/heads/dev/LOCK#.lock";
1982 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
1983 mock.insert_with(main_key, Bytes::new(), stale_ts, PutOpts::default());
1984 mock.insert_with(dev_key, Bytes::new(), stale_ts, PutOpts::default());
1985 mock.arm(Fault::NetworkOnDelete {
1988 key: main_key.to_owned(),
1989 });
1990
1991 let synthetic_listing = vec![
1994 ObjectMeta {
1995 key: main_key.to_owned(),
1996 size: 0,
1997 last_modified: stale_ts,
1998 etag: None,
1999 },
2000 ObjectMeta {
2001 key: dev_key.to_owned(),
2002 size: 0,
2003 last_modified: stale_ts,
2004 etag: None,
2005 },
2006 ];
2007
2008 let opts = DoctorOpts {
2009 delete_stale_locks: true,
2010 ..DoctorOpts::default()
2011 };
2012 let prompter = ScriptedPrompter::new([]);
2013 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
2014 let mut out = Vec::new();
2015 doctor
2016 .list_and_handle_stale_locks(&mut out, &synthetic_listing)
2017 .await
2018 .expect("delete failure must not abort the sweep");
2019 let captured = String::from_utf8(out).expect("utf-8 output");
2020
2021 assert!(
2022 mock.contains(main_key),
2023 "first key must survive its delete failure",
2024 );
2025 assert!(
2026 !mock.contains(dev_key),
2027 "second key must still be deleted after the first failure",
2028 );
2029 assert!(
2030 captured.contains(&format!("Failed to delete {main_key}")),
2031 "operator output missing delete-failure line for {main_key}: {captured:?}",
2032 );
2033 assert!(
2034 captured.contains(&format!("Deleted {dev_key}")),
2035 "operator output missing delete confirmation for {dev_key}: {captured:?}",
2036 );
2037 }
2038
2039 const DELETE_TTL: Duration = Duration::from_mins(1);
2050 const LOCK_KEY: &str = "myrepo/refs/heads/main/LOCK#.lock";
2051
2052 #[tokio::test]
2054 async fn helper_returns_deleted_when_head_confirms_stale() {
2055 let mock = MockStore::new();
2056 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
2057 mock.insert_with(LOCK_KEY, Bytes::new(), stale_ts, PutOpts::default());
2058
2059 let mut out = Vec::new();
2060 let outcome =
2061 super::delete_stale_lock_if_still_stale(&mock, LOCK_KEY, DELETE_TTL, &mut out)
2062 .await
2063 .expect("helper must not error on the happy path");
2064
2065 assert_eq!(outcome, DeleteOutcome::Deleted);
2066 assert!(!mock.contains(LOCK_KEY), "lock body must be removed");
2067 let captured = String::from_utf8(out).expect("utf-8 output");
2068 assert!(
2069 captured.contains(&format!("Deleted {LOCK_KEY}")),
2070 "delete confirmation missing: {captured:?}",
2071 );
2072 }
2073
2074 #[tokio::test]
2077 async fn helper_returns_skipped_not_stale_when_head_shows_fresh() {
2078 let mock = MockStore::new();
2079 mock.insert(LOCK_KEY, Bytes::new());
2081
2082 let mut out = Vec::new();
2083 let outcome =
2084 super::delete_stale_lock_if_still_stale(&mock, LOCK_KEY, DELETE_TTL, &mut out)
2085 .await
2086 .expect("helper must not error when refusing to delete");
2087
2088 assert_eq!(outcome, DeleteOutcome::SkippedNotStale);
2089 assert!(
2090 mock.contains(LOCK_KEY),
2091 "fresh lock must survive a stale-listing call",
2092 );
2093 let captured = String::from_utf8(out).expect("utf-8 output");
2094 assert!(
2095 captured.contains("no longer stale"),
2096 "skip-reason text missing: {captured:?}",
2097 );
2098 }
2099
2100 #[tokio::test]
2102 async fn helper_returns_skipped_disappeared_on_not_found() {
2103 let mock = MockStore::new();
2105 let mut out = Vec::new();
2106 let outcome =
2107 super::delete_stale_lock_if_still_stale(&mock, LOCK_KEY, DELETE_TTL, &mut out)
2108 .await
2109 .expect("vanished key must not surface as an error");
2110
2111 assert_eq!(outcome, DeleteOutcome::SkippedDisappeared);
2112 let captured = String::from_utf8(out).expect("utf-8 output");
2113 assert!(
2114 captured.contains("disappeared concurrently"),
2115 "disappeared-skip line missing: {captured:?}",
2116 );
2117 }
2118
2119 #[tokio::test]
2123 async fn helper_returns_skipped_head_error_on_transient_failure() {
2124 let mock = MockStore::new();
2125 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
2126 mock.insert_with(LOCK_KEY, Bytes::new(), stale_ts, PutOpts::default());
2127 mock.arm(Fault::NetworkOnHead {
2128 key: LOCK_KEY.to_owned(),
2129 });
2130
2131 let mut out = Vec::new();
2132 let outcome =
2133 super::delete_stale_lock_if_still_stale(&mock, LOCK_KEY, DELETE_TTL, &mut out)
2134 .await
2135 .expect("transient HEAD failure must not abort the sweep");
2136
2137 assert_eq!(outcome, DeleteOutcome::SkippedHeadError);
2138 assert!(
2139 mock.contains(LOCK_KEY),
2140 "lock must survive an inconclusive HEAD",
2141 );
2142 let captured = String::from_utf8(out).expect("utf-8 output");
2143 assert!(
2144 captured.contains("Failed to re-check"),
2145 "re-check failure line missing: {captured:?}",
2146 );
2147 }
2148
2149 #[tokio::test]
2153 async fn helper_returns_delete_failed_when_delete_errors() {
2154 let mock = MockStore::new();
2155 let stale_ts = OffsetDateTime::now_utc() - time::Duration::seconds(120);
2156 mock.insert_with(LOCK_KEY, Bytes::new(), stale_ts, PutOpts::default());
2157 mock.arm(Fault::NetworkOnDelete {
2158 key: LOCK_KEY.to_owned(),
2159 });
2160
2161 let mut out = Vec::new();
2162 let outcome =
2163 super::delete_stale_lock_if_still_stale(&mock, LOCK_KEY, DELETE_TTL, &mut out)
2164 .await
2165 .expect("delete failure must surface as Ok(DeleteFailed), not Err");
2166
2167 assert_eq!(outcome, DeleteOutcome::DeleteFailed);
2168 assert!(
2169 mock.contains(LOCK_KEY),
2170 "failed delete must not have removed the lock",
2171 );
2172 let captured = String::from_utf8(out).expect("utf-8 output");
2173 assert!(
2174 captured.contains(&format!("Failed to delete {LOCK_KEY}")),
2175 "delete-failure line missing: {captured:?}",
2176 );
2177 }
2178
2179 #[tokio::test]
2180 async fn report_renders_protected_multi_bundle_and_invalid_head() {
2181 let mock = MockStore::new();
2186 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/missing"));
2187 mock.insert(
2188 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2189 Bytes::from("b"),
2190 );
2191 mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
2192 mock.insert(
2193 format!("myrepo/refs/heads/dev/{SHA_A}.bundle"),
2194 Bytes::from("a"),
2195 );
2196 mock.insert(
2197 format!("myrepo/refs/heads/dev/{SHA_B}.bundle"),
2198 Bytes::from("a"),
2199 );
2200 mock.insert("myrepo/refs/heads/empty/PROTECTED#", Bytes::new());
2201
2202 let prompter = ScriptedPrompter::new([]);
2203 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2204 let snapshot = super::analyze_objects(
2205 &mock.list("myrepo/").await.expect("list"),
2206 "myrepo/",
2207 &store_arc(&mock),
2208 )
2209 .await
2210 .expect("analyze");
2211
2212 let report = doctor.report(&snapshot);
2213 assert_eq!(
2214 report,
2215 "myrepo:\n \
2216 refs/heads/dev: Multiple bundles\n \
2217 * refs/heads/empty: No bundles\n \
2218 * refs/heads/main: Ok\n \
2219 HEAD: Invalid\n",
2220 );
2221 }
2222
2223 #[tokio::test]
2224 async fn report_renders_valid_head_as_ref_label() {
2225 let mock = MockStore::new();
2226 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2227 mock.insert(
2228 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2229 Bytes::from("b"),
2230 );
2231 let prompter = ScriptedPrompter::new([]);
2232 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2233 let snapshot = super::analyze_objects(
2234 &mock.list("myrepo/").await.expect("list"),
2235 "myrepo/",
2236 &store_arc(&mock),
2237 )
2238 .await
2239 .expect("analyze");
2240
2241 let report = doctor.report(&snapshot);
2242 assert_eq!(
2243 report,
2244 "myrepo:\n refs/heads/main: Ok\n HEAD: refs/heads/main\n",
2245 );
2246 }
2247
2248 #[tokio::test]
2251 async fn root_prefix_clean_run_does_not_mutate_bucket() {
2252 let mock = MockStore::new();
2258 mock.insert("HEAD", Bytes::from("refs/heads/main"));
2259 mock.insert(
2260 format!("refs/heads/main/{SHA_A}.bundle"),
2261 Bytes::from("body"),
2262 );
2263 let initial_keys = mock.keys();
2264 let prompter = ScriptedPrompter::new([]);
2265 let doctor = Doctor::new(store_arc(&mock), "", DoctorOpts::default(), &prompter);
2266 doctor
2267 .run_into(&mut std::io::sink())
2268 .await
2269 .expect("doctor.run at root");
2270 assert_eq!(mock.keys(), initial_keys);
2271 }
2272
2273 #[tokio::test]
2274 async fn root_prefix_fix_head_writes_to_root_head_key() {
2275 let mock = MockStore::new();
2278 mock.insert(format!("refs/heads/main/{SHA_A}.bundle"), Bytes::from("b"));
2279 let prompter = ScriptedPrompter::new([Answer::Select(0)]);
2280 let doctor = Doctor::new(store_arc(&mock), "", DoctorOpts::default(), &prompter);
2281 doctor
2282 .run_into(&mut std::io::sink())
2283 .await
2284 .expect("doctor.run at root");
2285
2286 let head_bytes = mock.get_bytes("HEAD").await.expect("HEAD at root");
2287 assert_eq!(&head_bytes[..], b"refs/heads/main");
2288 assert!(!mock.contains("/HEAD"), "no leading-slash HEAD key");
2289 }
2290
2291 #[tokio::test]
2292 async fn root_prefix_fix_multiple_bundles_quarantines_at_root() {
2293 let mock = MockStore::new();
2294 mock.insert("HEAD", Bytes::from("refs/heads/main"));
2295 mock.insert(format!("refs/heads/main/{SHA_A}.bundle"), Bytes::from("a"));
2296 mock.insert(format!("refs/heads/main/{SHA_B}.bundle"), Bytes::from("b"));
2297 let prompter = ScriptedPrompter::new([Answer::Select(0), Answer::Confirm(true)]);
2298 let doctor = Doctor::new(store_arc(&mock), "", DoctorOpts::default(), &prompter);
2299 doctor
2300 .run_into(&mut std::io::sink())
2301 .await
2302 .expect("doctor.run at root");
2303
2304 let moved = mock
2307 .keys()
2308 .into_iter()
2309 .find(|k| k.starts_with("refs/heads/main_") && k.ends_with(&format!("/{SHA_B}.bundle")))
2310 .expect("quarantine key created at root");
2311 assert!(
2312 !moved.starts_with('/'),
2313 "quarantine key must not have a leading slash: {moved:?}"
2314 );
2315 }
2316
2317 #[tokio::test]
2320 async fn bundle_engine_clean_run_does_not_mutate_bucket() {
2321 let mock = MockStore::new();
2329 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2330 mock.insert(
2331 format!("repo/refs/heads/main/{SHA_A}.bundle"),
2332 Bytes::from("b"),
2333 );
2334 let initial_keys = mock.keys();
2335 let prompter = ScriptedPrompter::new([]);
2336 let doctor = Doctor::new(store_arc(&mock), "repo", DoctorOpts::default(), &prompter);
2337 doctor
2338 .run_into(&mut std::io::sink())
2339 .await
2340 .expect("clean bundle run");
2341 assert_eq!(mock.keys(), initial_keys);
2342 }
2343
2344 async fn packchain_mock_with_listing() -> (MockStore, Vec<ObjectMeta>) {
2350 let mock = MockStore::new();
2351 mock.insert(
2352 "repo/refs/heads/main/chain.json",
2353 Bytes::from(
2354 r#"{"v":1,"tip":"0000000000000000000000000000000000000001","full_at":"0000000000000000000000000000000000000001","segments":[{"sha":"0000000000000000000000000000000000000001","parent_sha":null,"pack":"packs/1111111111111111111111111111111111111111.pack","bytes":1024}]}"#,
2355 ),
2356 );
2357 mock.insert(
2358 "repo/packs/1111111111111111111111111111111111111111.pack",
2359 Bytes::from_static(b"live"),
2360 );
2361 mock.insert(
2362 "repo/packs/2222222222222222222222222222222222222222.pack",
2363 Bytes::from_static(b"orphan-body-len-eq-19"),
2364 );
2365 let marked_at = (OffsetDateTime::now_utc() - time::Duration::hours(2))
2366 .format(&time::format_description::well_known::Rfc3339)
2367 .unwrap();
2368 let tombstone_body = format!(
2369 r#"{{"v":1,"run_id":"abc-1","marked_at":"{marked_at}","orphan_packs":["2222222222222222222222222222222222222222"]}}"#
2370 );
2371 let tombstone_key = format!("repo/gc/tombstones-abc-1-{marked_at}.json");
2372 mock.insert(tombstone_key, Bytes::from(tombstone_body));
2373
2374 let objects = mock.list("repo/").await.expect("list");
2375 (mock, objects)
2376 }
2377
2378 #[tokio::test]
2379 async fn packchain_engine_renders_section_with_orphan_and_tombstone() {
2380 let (mock, objects) = packchain_mock_with_listing().await;
2383 let prompter = ScriptedPrompter::new([]);
2384 let doctor = Doctor::new(
2385 store_arc(&mock),
2386 "repo",
2387 DoctorOpts {
2388 engine: StorageEngine::Packchain,
2389 ..DoctorOpts::default()
2390 },
2391 &prompter,
2392 );
2393 let rendered = doctor
2394 .maybe_render_packchain_section(&objects)
2395 .await
2396 .expect("packchain audit succeeds")
2397 .expect("packchain engine produces a section");
2398
2399 assert!(rendered.contains("=== Packchain ==="), "{rendered}");
2402 assert!(rendered.contains("Orphans: 1 pack(s)"), "{rendered}");
2403 assert!(rendered.contains("21 B"), "{rendered}");
2404 assert!(rendered.contains("run id abc-1"), "{rendered}");
2405 assert!(rendered.contains("1 pack(s)"), "{rendered}");
2406 }
2407
2408 #[tokio::test]
2409 async fn bundle_engine_returns_no_packchain_section() {
2410 let (mock, objects) = packchain_mock_with_listing().await;
2415 let prompter = ScriptedPrompter::new([]);
2416 let doctor = Doctor::new(store_arc(&mock), "repo", DoctorOpts::default(), &prompter);
2417 let rendered = doctor
2418 .maybe_render_packchain_section(&objects)
2419 .await
2420 .expect("audit gate skips cleanly under bundle");
2421 assert!(
2422 rendered.is_none(),
2423 "bundle engine must not render a packchain section, got: {rendered:?}",
2424 );
2425 }
2426
2427 #[test]
2428 fn render_packchain_section_lists_dangling_references_as_errors() {
2429 let report = AuditReport {
2432 orphans: super::audit::OrphanSummary::default(),
2433 tombstones: Vec::new(),
2434 branches: Vec::new(),
2435 dangling: vec![super::audit::DanglingRow {
2436 ref_path: "refs/heads/dev".to_owned(),
2437 missing_pack_key: "packs/abcdef0123456789abcdef0123456789abcdef01.pack".to_owned(),
2438 }],
2439 };
2440 let rendered = super::render_packchain_section(&report);
2441 assert!(rendered.contains("ERRORS:"));
2442 assert!(rendered.contains("references missing pack"));
2443 assert!(rendered.contains("refs/heads/dev"));
2444 }
2445
2446 #[test]
2447 fn render_packchain_section_clean_bucket_says_none() {
2448 let report = AuditReport::default();
2449 let rendered = super::render_packchain_section(&report);
2450 assert!(rendered.contains("=== Packchain ==="));
2451 assert!(rendered.contains("Orphans: 0 pack(s)"));
2452 assert!(rendered.contains("Tombstones (pending sweep): none"));
2453 assert!(rendered.contains("Branches needing compaction: none"));
2454 assert!(rendered.contains("ERRORS: none"));
2455 }
2456
2457 #[test]
2458 fn format_bytes_unit_boundaries() {
2459 assert_eq!(super::format_bytes(0), "0 B");
2460 assert_eq!(super::format_bytes(1023), "1023 B");
2461 assert_eq!(super::format_bytes(1024), "1.0 KiB");
2462 assert_eq!(super::format_bytes(1024 * 1024 - 1), "1024.0 KiB");
2463 assert_eq!(super::format_bytes(1024 * 1024), "1.0 MiB");
2464 assert_eq!(super::format_bytes(1024 * 1024 * 1024), "1.0 GiB");
2465 }
2466
2467 #[test]
2468 fn format_age_handles_clock_skew_and_rollover() {
2469 assert_eq!(super::format_age(-1), "<1h");
2471 assert_eq!(super::format_age(0), "<1h");
2473 assert_eq!(super::format_age(1), "1h ago");
2475 assert_eq!(super::format_age(47), "47h ago");
2476 assert_eq!(super::format_age(48), "2d ago");
2478 assert_eq!(super::format_age(72), "3d ago");
2479 }
2480
2481 #[test]
2482 fn render_packchain_section_compaction_candidate_is_flagged() {
2483 let report = AuditReport {
2484 orphans: super::audit::OrphanSummary::default(),
2485 tombstones: Vec::new(),
2486 branches: vec![BranchRow {
2487 ref_path: "refs/heads/main".to_owned(),
2488 segments_total: 27,
2489 bytes_total: 142 * 1024 * 1024,
2490 recommend_compact: true,
2491 has_full_at_segment: true,
2492 }],
2493 dangling: Vec::new(),
2494 };
2495 let rendered = super::render_packchain_section(&report);
2496 assert!(rendered.contains("refs/heads/main: 27 segment(s)"));
2497 assert!(rendered.contains("[recommend compact]"));
2498 assert!(rendered.contains("142.0 MiB"));
2499 }
2500
2501 #[tokio::test]
2504 async fn packchain_report_shows_no_gc_or_packs_lines() {
2505 let mock = MockStore::new();
2509 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2510 mock.insert("repo/refs/heads/main/chain.json", Bytes::from(r#"{"v":1}"#));
2511 mock.insert(
2512 "repo/packs/1111111111111111111111111111111111111111.pack",
2513 Bytes::from_static(b"live"),
2514 );
2515 mock.insert(
2516 "repo/gc/tombstones-abc-1-2025-01-01T00:00:00Z.json",
2517 Bytes::from_static(b"{}"),
2518 );
2519 mock.insert("repo/lfs/abcdef0123456789", Bytes::from("lfs-body"));
2520 let prompter = ScriptedPrompter::new([]);
2521 let store = store_arc(&mock);
2522 let doctor = Doctor::new(Arc::clone(&store), "repo", DoctorOpts::default(), &prompter);
2523 let snapshot = analyze(&*store, "repo").await.expect("analyze");
2524
2525 let report = doctor.report(&snapshot);
2526 assert!(
2528 !report.contains(" packs:"),
2529 "packs must not appear in report: {report:?}",
2530 );
2531 assert!(
2532 !report.contains(" gc:"),
2533 "gc must not appear in report: {report:?}",
2534 );
2535 assert!(
2536 !report.contains(" lfs:"),
2537 "lfs must not appear in report: {report:?}",
2538 );
2539 assert!(
2541 report.contains("refs/heads/main: Ok"),
2542 "chain.json ref must show Ok: {report:?}",
2543 );
2544 assert!(
2545 !report.contains("No bundles"),
2546 "healthy packchain report must have no 'No bundles' lines: {report:?}",
2547 );
2548 }
2549
2550 #[tokio::test]
2551 async fn packchain_chain_json_ref_reports_ok_not_no_bundles() {
2552 let mock = MockStore::new();
2556 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2557 mock.insert("repo/refs/heads/main/chain.json", Bytes::from(r#"{"v":1}"#));
2558 let prompter = ScriptedPrompter::new([]);
2559 let store = store_arc(&mock);
2560 let doctor = Doctor::new(Arc::clone(&store), "repo", DoctorOpts::default(), &prompter);
2561 let snapshot = analyze(&*store, "repo").await.expect("analyze");
2562
2563 let report = doctor.report(&snapshot);
2564 assert_eq!(
2565 report, "repo:\n refs/heads/main: Ok\n HEAD: refs/heads/main\n",
2566 "chain.json ref must report Ok, got: {report:?}",
2567 );
2568 }
2569
2570 #[tokio::test]
2571 async fn bundle_engine_report_unchanged_by_chain_json_fix() {
2572 let mock = MockStore::new();
2576 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/missing"));
2577 mock.insert(
2578 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2579 Bytes::from("b"),
2580 );
2581 mock.insert("myrepo/refs/heads/empty/PROTECTED#", Bytes::new());
2582 let prompter = ScriptedPrompter::new([]);
2583 let store = store_arc(&mock);
2584 let doctor = Doctor::new(
2585 Arc::clone(&store),
2586 "myrepo",
2587 DoctorOpts::default(),
2588 &prompter,
2589 );
2590 let snapshot = analyze(&*store, "myrepo").await.expect("analyze");
2591
2592 let report = doctor.report(&snapshot);
2593 assert!(
2595 report.contains("refs/heads/empty: No bundles"),
2596 "empty ref without chain.json must still show No bundles: {report:?}",
2597 );
2598 assert!(
2600 report.contains("refs/heads/main: Ok"),
2601 "single-bundle ref must show Ok: {report:?}",
2602 );
2603 }
2604
2605 #[tokio::test]
2606 async fn packchain_multiple_bundles_still_reports_multiple() {
2607 let mock = MockStore::new();
2612 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2613 mock.insert("repo/refs/heads/main/chain.json", Bytes::from(r#"{"v":1}"#));
2614 mock.insert(
2615 format!("repo/refs/heads/main/{SHA_A}.bundle"),
2616 Bytes::from("a"),
2617 );
2618 mock.insert(
2619 format!("repo/refs/heads/main/{SHA_B}.bundle"),
2620 Bytes::from("b"),
2621 );
2622 let prompter = ScriptedPrompter::new([]);
2623 let store = store_arc(&mock);
2624 let doctor = Doctor::new(Arc::clone(&store), "repo", DoctorOpts::default(), &prompter);
2625 let snapshot = analyze(&*store, "repo").await.expect("analyze");
2626
2627 let report = doctor.report(&snapshot);
2628 assert!(
2629 report.contains("refs/heads/main: Multiple bundles"),
2630 "packchain ref with multiple bundles must still warn: {report:?}",
2631 );
2632 }
2633
2634 #[tokio::test]
2635 async fn packchain_chain_json_with_one_bundle_reports_ok() {
2636 let mock = MockStore::new();
2641 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2642 mock.insert("repo/refs/heads/main/chain.json", Bytes::from(r#"{"v":1}"#));
2643 mock.insert(
2644 format!("repo/refs/heads/main/{SHA_A}.bundle"),
2645 Bytes::from("b"),
2646 );
2647 let prompter = ScriptedPrompter::new([]);
2648 let store = store_arc(&mock);
2649 let doctor = Doctor::new(Arc::clone(&store), "repo", DoctorOpts::default(), &prompter);
2650 let snapshot = analyze(&*store, "repo").await.expect("analyze");
2651
2652 let report = doctor.report(&snapshot);
2653 assert_eq!(
2654 report, "repo:\n refs/heads/main: Ok\n HEAD: refs/heads/main\n",
2655 "chain.json + 1 bundle must report Ok, got: {report:?}",
2656 );
2657 }
2658
2659 #[tokio::test]
2660 async fn packchain_protected_ref_shows_star_and_ok() {
2661 let mock = MockStore::new();
2664 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2665 mock.insert("repo/refs/heads/main/chain.json", Bytes::from(r#"{"v":1}"#));
2666 mock.insert("repo/refs/heads/main/PROTECTED#", Bytes::new());
2667 let prompter = ScriptedPrompter::new([]);
2668 let store = store_arc(&mock);
2669 let doctor = Doctor::new(Arc::clone(&store), "repo", DoctorOpts::default(), &prompter);
2670 let snapshot = analyze(&*store, "repo").await.expect("analyze");
2671
2672 let report = doctor.report(&snapshot);
2673 assert!(
2674 report.contains("* refs/heads/main: Ok"),
2675 "protected packchain ref must show star + Ok: {report:?}",
2676 );
2677 }
2678
2679 #[tokio::test]
2680 async fn root_prefix_report_renders_root_label() {
2681 let mock = MockStore::new();
2684 mock.insert("HEAD", Bytes::from("refs/heads/main"));
2685 mock.insert(format!("refs/heads/main/{SHA_A}.bundle"), Bytes::from("b"));
2686 let prompter = ScriptedPrompter::new([]);
2687 let doctor = Doctor::new(store_arc(&mock), "", DoctorOpts::default(), &prompter);
2688 let snapshot = super::analyze_objects(
2689 &mock.list("").await.expect("list at root"),
2690 "",
2691 &store_arc(&mock),
2692 )
2693 .await
2694 .expect("analyze");
2695
2696 let report = doctor.report(&snapshot);
2697 assert_eq!(
2698 report,
2699 "(root):\n refs/heads/main: Ok\n HEAD: refs/heads/main\n",
2700 );
2701 }
2702
2703 async fn capture_run(doctor: &Doctor<'_>) -> (Result<(), ManageError>, String) {
2708 let mut buf = Vec::new();
2709 let result = doctor.run_into(&mut buf).await;
2710 let output = String::from_utf8(buf).expect("doctor output is valid UTF-8");
2711 (result, output)
2712 }
2713
2714 #[tokio::test]
2715 async fn run_into_bundle_engine_section_order() {
2716 let mock = MockStore::new();
2721 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2722 mock.insert(
2723 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2724 Bytes::from("b"),
2725 );
2726 let prompter = ScriptedPrompter::new([]);
2727 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2728 let (result, output) = capture_run(&doctor).await;
2729 result.expect("clean bundle run");
2730
2731 assert!(
2733 output.starts_with("myrepo:\n"),
2734 "output must start with snapshot report header, got: {output:?}",
2735 );
2736 assert!(
2738 output.contains("\nScanning for stale locks...\n"),
2739 "stale-lock scan missing from output: {output:?}",
2740 );
2741 assert!(
2742 output.contains("No stale locks found.\n"),
2743 "no-stale-locks trailer missing: {output:?}",
2744 );
2745 assert!(
2747 !output.contains("=== Packchain ==="),
2748 "packchain section must not appear under bundle engine: {output:?}",
2749 );
2750
2751 let report_pos = output.find("myrepo:").expect("report header");
2753 let locks_pos = output
2754 .find("Scanning for stale locks")
2755 .expect("stale-lock scan");
2756 assert!(
2757 report_pos < locks_pos,
2758 "snapshot report must precede stale-lock scan"
2759 );
2760 }
2761
2762 #[tokio::test]
2763 async fn run_into_packchain_engine_section_order() {
2764 let mock = MockStore::new();
2768 mock.insert("repo/HEAD", Bytes::from("refs/heads/main"));
2769 mock.insert(
2770 format!("repo/refs/heads/main/{SHA_A}.bundle"),
2771 Bytes::from("b"),
2772 );
2773 mock.insert(
2775 "repo/refs/heads/main/chain.json",
2776 Bytes::from(
2777 r#"{"v":1,"tip":"0000000000000000000000000000000000000001","full_at":"0000000000000000000000000000000000000001","segments":[{"sha":"0000000000000000000000000000000000000001","parent_sha":null,"pack":"packs/1111111111111111111111111111111111111111.pack","bytes":1024}]}"#,
2778 ),
2779 );
2780 mock.insert(
2781 "repo/packs/1111111111111111111111111111111111111111.pack",
2782 Bytes::from_static(b"live"),
2783 );
2784 let prompter = ScriptedPrompter::new([]);
2785 let doctor = Doctor::new(
2786 store_arc(&mock),
2787 "repo",
2788 DoctorOpts {
2789 engine: StorageEngine::Packchain,
2790 ..DoctorOpts::default()
2791 },
2792 &prompter,
2793 );
2794 let (result, output) = capture_run(&doctor).await;
2795 result.expect("clean packchain run");
2796
2797 let report_pos = output.find("repo:").expect("snapshot report header");
2799 let packchain_pos = output.find("=== Packchain ===").expect("packchain section");
2800 let locks_pos = output
2801 .find("Scanning for stale locks")
2802 .expect("stale-lock scan");
2803 assert!(
2804 report_pos < packchain_pos,
2805 "snapshot report must precede packchain section"
2806 );
2807 assert!(
2808 packchain_pos < locks_pos,
2809 "packchain section must precede stale-lock scan"
2810 );
2811 }
2812
2813 #[tokio::test]
2814 async fn run_into_captures_fix_multiple_bundles_output() {
2815 let mock = MockStore::new();
2818 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2819 mock.insert(
2820 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2821 Bytes::from("body-a"),
2822 );
2823 mock.insert(
2824 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
2825 Bytes::from("body-b"),
2826 );
2827 let prompter = ScriptedPrompter::new([Answer::Select(0), Answer::Confirm(true)]);
2828 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2829 let (result, output) = capture_run(&doctor).await;
2830 result.expect("fix-multiple run");
2831
2832 assert!(
2833 output.contains("Fix multiple bundles for repo myrepo and ref refs/heads/main"),
2834 "fixer header missing: {output:?}",
2835 );
2836 assert!(
2837 output.contains(&format!("Keeping {SHA_A}")),
2838 "keeper announcement missing: {output:?}",
2839 );
2840 assert!(
2841 output.contains(&format!("Moving {SHA_B} to new branch")),
2842 "eviction line missing: {output:?}",
2843 );
2844 }
2845
2846 #[tokio::test]
2847 async fn run_into_captures_fix_head_output() {
2848 let mock = MockStore::new();
2850 mock.insert(
2851 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2852 Bytes::from("b"),
2853 );
2854 let prompter = ScriptedPrompter::new([Answer::Select(0)]);
2855 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2856 let (result, output) = capture_run(&doctor).await;
2857 result.expect("fix-head run");
2858
2859 assert!(
2860 output.contains("Fix invalid HEAD for repo myrepo"),
2861 "HEAD fixer header missing: {output:?}",
2862 );
2863 assert!(
2864 output.contains("Setting refs/heads/main as HEAD"),
2865 "HEAD assignment line missing: {output:?}",
2866 );
2867 }
2868
2869 #[tokio::test]
2870 async fn fix_head_offered_when_head_points_at_protected_only_ref() {
2871 let mock = MockStore::new();
2881 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2882 mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
2883 mock.insert(
2884 format!("myrepo/refs/heads/dev/{SHA_C}.bundle"),
2885 Bytes::from("c"),
2886 );
2887 let prompter = ScriptedPrompter::new([Answer::Select(0)]);
2890 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2891 let (result, output) = capture_run(&doctor).await;
2892 result.expect("doctor.run");
2893
2894 assert!(
2896 output.contains("HEAD: Invalid"),
2897 "expected `HEAD: Invalid` in report; got:\n{output}",
2898 );
2899 assert!(
2901 output.contains("Fix invalid HEAD for repo myrepo"),
2902 "fix_head header missing; got:\n{output}",
2903 );
2904 assert!(
2905 output.contains("Setting refs/heads/dev as HEAD"),
2906 "HEAD reassignment line missing; got:\n{output}",
2907 );
2908 let head_bytes = mock.get_bytes("myrepo/HEAD").await.expect("HEAD written");
2910 assert_eq!(&head_bytes[..], b"refs/heads/dev");
2911 assert_eq!(prompter.remaining(), 0);
2912 }
2913
2914 #[tokio::test]
2915 async fn fix_head_skipped_when_head_points_at_protected_ref_with_bundle() {
2916 let mock = MockStore::new();
2923 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2924 mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
2925 mock.insert(
2926 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2927 Bytes::from("b"),
2928 );
2929 let initial_keys = mock.keys();
2930 let prompter = ScriptedPrompter::new([]);
2931 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2932 let (result, output) = capture_run(&doctor).await;
2933 result.expect("doctor.run");
2934
2935 assert!(
2937 !output.contains("Fix invalid HEAD"),
2938 "fix_head fired against a protected ref with real data: {output}",
2939 );
2940 assert!(
2941 output.contains("HEAD: refs/heads/main"),
2942 "expected valid HEAD label in report; got:\n{output}",
2943 );
2944 assert_eq!(mock.keys(), initial_keys);
2945 assert_eq!(prompter.remaining(), 0);
2946 }
2947
2948 #[tokio::test]
2949 async fn fix_head_reports_no_candidates_when_all_refs_are_protected_only() {
2950 let mock = MockStore::new();
2958 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2959 mock.insert("myrepo/refs/heads/main/PROTECTED#", Bytes::new());
2960 mock.insert("myrepo/refs/heads/dev/PROTECTED#", Bytes::new());
2961 let prompter = ScriptedPrompter::new([]);
2962 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
2963 let (result, output) = capture_run(&doctor).await;
2964 result.expect("doctor.run");
2965
2966 assert!(
2967 output.contains("HEAD: Invalid"),
2968 "expected `HEAD: Invalid` in report; got:\n{output}",
2969 );
2970 assert!(
2971 output.contains("Fix invalid HEAD for repo myrepo"),
2972 "fix_head header missing; got:\n{output}",
2973 );
2974 assert!(
2979 output.contains("No `refs/heads/*` available to assign as HEAD"),
2980 "candidate picker must surface the no-candidates message: {output}",
2981 );
2982 assert_eq!(prompter.remaining(), 0);
2983 }
2984
2985 #[tokio::test]
2986 async fn run_into_captures_stale_lock_output() {
2987 let mock = MockStore::new();
2990 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
2991 mock.insert(
2992 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
2993 Bytes::from("b"),
2994 );
2995 let stale = OffsetDateTime::now_utc() - time::Duration::seconds(120);
2996 mock.insert_with(
2997 "myrepo/refs/heads/main/LOCK#.lock",
2998 Bytes::new(),
2999 stale,
3000 PutOpts::default(),
3001 );
3002 let opts = DoctorOpts {
3003 delete_stale_locks: true,
3004 ..DoctorOpts::default()
3005 };
3006 let prompter = ScriptedPrompter::new([]);
3007 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
3008 let (result, output) = capture_run(&doctor).await;
3009 result.expect("stale-lock-delete run");
3010
3011 assert!(
3012 output.contains("Found stale locks:"),
3013 "stale-lock listing header missing: {output:?}",
3014 );
3015 assert!(
3016 output.contains("Deleting stale locks..."),
3017 "deletion progress line missing: {output:?}",
3018 );
3019 assert!(
3020 output.contains("Deleted myrepo/refs/heads/main/LOCK#.lock"),
3021 "individual deletion confirmation missing: {output:?}",
3022 );
3023 }
3024
3025 #[tokio::test]
3028 async fn malformed_bundle_key_surfaces_in_doctor_output() {
3029 let mock = MockStore::new();
3033 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
3034 mock.insert(
3035 "myrepo/refs/heads/main/0123456789abcdef0123456789abcdef01234567.bundle",
3036 Bytes::from("body"),
3037 );
3038 mock.insert(
3039 "myrepo/refs/heads/main/not-a-valid-sha.bundle",
3040 Bytes::from("junk"),
3041 );
3042 let prompter = ScriptedPrompter::new([]);
3043 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
3044 let (result, output) = capture_run(&doctor).await;
3045 result.expect("doctor.run");
3046
3047 assert!(
3048 output.contains("Malformed bundle keys"),
3049 "section header missing: {output:?}",
3050 );
3051 assert!(
3052 output.contains("myrepo/refs/heads/main/not-a-valid-sha.bundle"),
3053 "malformed key not listed: {output:?}",
3054 );
3055 assert!(
3056 output.contains("(ref refs/heads/main)"),
3057 "ref-path context missing: {output:?}",
3058 );
3059 assert!(
3060 output.contains("Delete each key manually"),
3061 "remediation hint missing: {output:?}",
3062 );
3063
3064 assert!(
3066 mock.contains("myrepo/refs/heads/main/not-a-valid-sha.bundle"),
3067 "doctor must not auto-delete malformed bundle keys",
3068 );
3069 assert!(mock.contains(
3071 "myrepo/refs/heads/main/0123456789abcdef0123456789abcdef01234567.bundle",
3072 ));
3073 }
3074
3075 #[tokio::test]
3076 async fn clean_bucket_emits_no_malformed_section() {
3077 let mock = MockStore::new();
3078 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
3079 mock.insert(
3080 "myrepo/refs/heads/main/0123456789abcdef0123456789abcdef01234567.bundle",
3081 Bytes::from("body"),
3082 );
3083 let prompter = ScriptedPrompter::new([]);
3084 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
3085 let (result, output) = capture_run(&doctor).await;
3086 result.expect("clean run");
3087 assert!(
3088 !output.contains("Malformed bundle keys"),
3089 "no-malformed runs must not emit the section: {output:?}",
3090 );
3091 }
3092
3093 #[tokio::test]
3094 async fn well_formed_stem_is_not_flagged_as_malformed() {
3095 let mock = MockStore::new();
3099 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
3100 mock.insert(
3101 "myrepo/refs/heads/main/0123456789abcdef0123456789abcdef01234567.bundle",
3102 Bytes::from("body"),
3103 );
3104 let prompter = ScriptedPrompter::new([]);
3105 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
3106 let (result, output) = capture_run(&doctor).await;
3107 result.expect("clean run");
3108 assert!(!output.contains("Malformed bundle keys"));
3109 assert!(!output.contains("0123456789abcdef0123456789abcdef01234567.bundle (ref"));
3110 }
3111
3112 #[test]
3113 fn render_malformed_bundles_section_pins_format() {
3114 let entries = vec![
3115 MalformedBundleKey {
3116 ref_path: "refs/heads/main".to_owned(),
3117 key: "myrepo/refs/heads/main/not-a-valid-sha.bundle".to_owned(),
3118 },
3119 MalformedBundleKey {
3120 ref_path: "refs/heads/dev".to_owned(),
3121 key: "myrepo/refs/heads/dev/short.bundle".to_owned(),
3122 },
3123 ];
3124 let rendered = super::render_malformed_bundles_section(&entries);
3125 assert_eq!(
3126 rendered,
3127 "\nMalformed bundle keys (push silently ignores these):\n \
3128 - myrepo/refs/heads/main/not-a-valid-sha.bundle (ref refs/heads/main)\n \
3129 - myrepo/refs/heads/dev/short.bundle (ref refs/heads/dev)\n \
3130 Delete each key manually (`aws s3 rm` / `az storage blob delete`) and re-push the ref.\n",
3131 );
3132 }
3133
3134 #[tokio::test]
3135 async fn run_into_captures_aborted_output() {
3136 let mock = MockStore::new();
3141 mock.insert("myrepo/HEAD", Bytes::from("refs/heads/main"));
3142 mock.insert(
3143 format!("myrepo/refs/heads/main/{SHA_A}.bundle"),
3144 Bytes::from("a"),
3145 );
3146 mock.insert(
3147 format!("myrepo/refs/heads/main/{SHA_B}.bundle"),
3148 Bytes::from("b"),
3149 );
3150 let prompter = ScriptedPrompter::new([Answer::Select(0), Answer::Confirm(false)]);
3151 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
3152 let (result, output) = capture_run(&doctor).await;
3153 result.expect("user-abort run");
3154
3155 assert!(
3156 output.contains("Fix multiple bundles for repo myrepo"),
3157 "fixer header missing: {output:?}",
3158 );
3159 assert!(
3160 output.contains("Aborted"),
3161 "abort message missing: {output:?}",
3162 );
3163 assert!(
3166 !output.contains("Keeping"),
3167 "keeper line must not appear after user abort: {output:?}",
3168 );
3169 assert!(
3170 !output.contains("Moving"),
3171 "eviction line must not appear after user abort: {output:?}",
3172 );
3173 }
3174
3175 #[test]
3184 fn default_lock_ttl_is_none() {
3185 assert!(DoctorOpts::default().lock_ttl_seconds.is_none());
3186 }
3187
3188 #[test]
3189 fn resolved_lock_ttl_honors_env_explicit_and_zero() {
3190 use crate::protocol::push::ENV_LOCK_TTL_SECONDS;
3197 let env = crate::test_util::EnvGuard::take(ENV_LOCK_TTL_SECONDS);
3198 let mock = MockStore::new();
3199 let prompter = ScriptedPrompter::new([]);
3200 let doctor = Doctor::new(store_arc(&mock), "myrepo", DoctorOpts::default(), &prompter);
3201
3202 env.set_to("120");
3204 assert_eq!(
3205 doctor.resolved_lock_ttl_seconds(),
3206 120,
3207 "env override must propagate to Doctor default",
3208 );
3209
3210 env.clear();
3212 assert_eq!(
3213 doctor.resolved_lock_ttl_seconds(),
3214 DEFAULT_LOCK_TTL_SECONDS,
3215 "unset env must fall back to DEFAULT_LOCK_TTL_SECONDS",
3216 );
3217
3218 let opts = DoctorOpts {
3220 lock_ttl_seconds: Some(7),
3221 ..DoctorOpts::default()
3222 };
3223 let doctor_with_opt = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
3224 env.set_to("120");
3225 assert_eq!(
3226 doctor_with_opt.resolved_lock_ttl_seconds(),
3227 7,
3228 "explicit opts.lock_ttl_seconds must win",
3229 );
3230
3231 env.clear();
3237 let zero_opts = DoctorOpts {
3238 lock_ttl_seconds: Some(0),
3239 ..DoctorOpts::default()
3240 };
3241 let doctor_zero = Doctor::new(store_arc(&mock), "myrepo", zero_opts, &prompter);
3242 assert_eq!(
3243 doctor_zero.resolved_lock_ttl_seconds(),
3244 0,
3245 "explicit Some(0) must pass through to scan_stale_locks",
3246 );
3247 env.set_to("240");
3250 assert_eq!(
3251 doctor_zero.resolved_lock_ttl_seconds(),
3252 0,
3253 "explicit Some(0) must override the env var (operator opt-in)",
3254 );
3255 }
3256
3257 #[tokio::test]
3266 async fn scan_stale_locks_with_zero_ttl_flags_every_lock() {
3267 let now = OffsetDateTime::now_utc();
3268 let listing = [
3269 ObjectMeta {
3270 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
3271 size: 0,
3272 last_modified: now - Duration::from_secs(1),
3273 etag: None,
3274 },
3275 ObjectMeta {
3276 key: "myrepo/refs/heads/feature/LOCK#.lock".to_owned(),
3277 size: 0,
3278 last_modified: now - Duration::from_hours(1),
3279 etag: None,
3280 },
3281 ];
3282 let stale = scan_stale_locks(&listing, Duration::ZERO);
3283 let keys: Vec<&str> = stale.iter().map(|(k, _)| *k).collect();
3284 assert_eq!(
3285 keys,
3286 vec![
3287 "myrepo/refs/heads/main/LOCK#.lock",
3288 "myrepo/refs/heads/feature/LOCK#.lock",
3289 ],
3290 "TTL=0 must flag every lock with a positive age as stale",
3291 );
3292 }
3293
3294 #[tokio::test]
3306 async fn scan_stale_locks_treats_age_equal_to_ttl_as_fresh() {
3307 let now = OffsetDateTime::now_utc();
3308 let ttl = Duration::from_mins(1);
3309 let listing = [ObjectMeta {
3310 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
3311 size: 0,
3312 last_modified: now - time::Duration::minutes(1),
3313 etag: None,
3314 }];
3315 let stale = scan_stale_locks_at(now, &listing, ttl);
3316 assert!(
3317 stale.is_empty(),
3318 "lock with age == ttl must NOT be flagged (contract is `age > ttl`, \
3319 not `>=`); a regression to `>=` would flag this lock: {stale:?}",
3320 );
3321 }
3322
3323 #[tokio::test]
3331 async fn scan_stale_locks_evicts_future_stamped_at_zero_ttl() {
3332 let now = OffsetDateTime::now_utc();
3333 let listing = [ObjectMeta {
3334 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
3335 size: 0,
3336 last_modified: now + time::Duration::minutes(1),
3338 etag: None,
3339 }];
3340 let stale = scan_stale_locks_at(now, &listing, Duration::ZERO);
3341 let keys: Vec<&str> = stale.iter().map(|(k, _)| *k).collect();
3342 assert_eq!(
3343 keys,
3344 vec!["myrepo/refs/heads/main/LOCK#.lock"],
3345 "TTL=0 must evict future-stamped locks (operator-explicit \
3346 treat-every-lock-as-stale opt-in)",
3347 );
3348 }
3349
3350 #[tokio::test]
3356 async fn scan_stale_locks_keeps_future_stamped_at_positive_ttl() {
3357 let now = OffsetDateTime::now_utc();
3358 let listing = [ObjectMeta {
3359 key: "myrepo/refs/heads/main/LOCK#.lock".to_owned(),
3360 size: 0,
3361 last_modified: now + time::Duration::minutes(1),
3362 etag: None,
3363 }];
3364 let stale = scan_stale_locks_at(now, &listing, Duration::from_mins(1));
3365 assert!(
3366 stale.is_empty(),
3367 "positive TTL must NOT flag a future-stamped lock: {stale:?}",
3368 );
3369 }
3370
3371 #[tokio::test]
3378 async fn delete_stale_lock_if_still_stale_evicts_future_stamped_at_zero_ttl() {
3379 let mock = MockStore::new();
3380 let key = "myrepo/refs/heads/main/LOCK#.lock";
3381 let future = OffsetDateTime::now_utc() + time::Duration::minutes(1);
3382 mock.insert_with(key, Bytes::new(), future, PutOpts::default());
3383
3384 let mut out = Vec::new();
3385 let outcome = super::delete_stale_lock_if_still_stale(&mock, key, Duration::ZERO, &mut out)
3386 .await
3387 .expect("helper must not error on the future-stamped path");
3388
3389 assert_eq!(outcome, DeleteOutcome::Deleted);
3390 assert!(
3391 !mock.contains(key),
3392 "future-stamped lock must be deleted at TTL=0"
3393 );
3394 }
3395
3396 #[tokio::test]
3402 async fn delete_stale_lock_if_still_stale_skips_future_stamped_at_positive_ttl() {
3403 let mock = MockStore::new();
3404 let key = "myrepo/refs/heads/main/LOCK#.lock";
3405 let future = OffsetDateTime::now_utc() + time::Duration::minutes(1);
3406 mock.insert_with(key, Bytes::new(), future, PutOpts::default());
3407
3408 let mut out = Vec::new();
3409 let outcome =
3410 super::delete_stale_lock_if_still_stale(&mock, key, Duration::from_mins(1), &mut out)
3411 .await
3412 .expect("helper must not error when refusing a future-stamped lock");
3413
3414 assert_eq!(outcome, DeleteOutcome::SkippedNotStale);
3415 assert!(
3416 mock.contains(key),
3417 "future-stamped lock must survive at positive TTL",
3418 );
3419 }
3420
3421 #[tokio::test]
3438 async fn doctor_with_explicit_zero_lock_ttl_deletes_fresh_lock() {
3439 let mock = MockStore::new();
3440 let lock_key = "myrepo/refs/heads/main/LOCK#.lock";
3441 mock.insert(lock_key, Bytes::new());
3446 let synthetic_listing = vec![ObjectMeta {
3447 key: lock_key.to_owned(),
3448 size: 0,
3449 last_modified: OffsetDateTime::now_utc(),
3450 etag: None,
3451 }];
3452
3453 let opts = DoctorOpts {
3454 lock_ttl_seconds: Some(0),
3455 delete_stale_locks: true,
3456 ..DoctorOpts::default()
3457 };
3458 let prompter = ScriptedPrompter::new([]);
3459 let doctor = Doctor::new(store_arc(&mock), "myrepo", opts, &prompter);
3460 let mut out = Vec::new();
3461 doctor
3462 .list_and_handle_stale_locks(&mut out, &synthetic_listing)
3463 .await
3464 .expect("stale-lock handler");
3465 let captured = String::from_utf8(out).expect("utf-8 output");
3466
3467 assert!(
3468 !mock.contains(lock_key),
3469 "explicit --lock-ttl-seconds 0 must evict the fresh lock; \
3470 a regression that clamped Some(0) to env-or-default would \
3471 leave the lock in place. Captured output: {captured:?}",
3472 );
3473 assert!(
3474 captured.contains(&format!("Deleted {lock_key}")),
3475 "per-key deletion line missing from operator output: {captured:?}",
3476 );
3477 }
3478}