1use std::path::{Path, PathBuf};
29
30use crate::config::{ConfigFile, ConfigScope, ConfigSet};
31use crate::environment::RepositoryOptions;
32use crate::error::{Error, Result};
33use crate::index_pack::{install_pack_path, IngestPackOptions};
34use crate::maintenance::{GcLock, GcLockAttempt, GcLockHolder, RepackOptions};
35use crate::objects::{
36 oid_hash_set, CommitRef, HashAlgo, ObjectId, ObjectKind, OidHashMap, OidHashSet, TreeRefIter,
37};
38use crate::odb::Odb;
39use crate::pack_index::PackIndex;
40use crate::pack_objects::PackBuildOptions;
41use crate::partial_clone::{stored_remote_filter, ObjectFilterSpec};
42use crate::repo::Repository;
43
44pub const LOP_KEEP_MESSAGE: &str = "grit offload: large-object promisor pack";
46
47pub const LOP_GIT_DIR_KEY: &str = "grit.lopGitDir";
55
56#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct OffloadOptions {
60 pub remote: String,
63 pub filter: Option<ObjectFilterSpec>,
68 pub lop_git_dir: Option<PathBuf>,
72 pub configure_lop: bool,
75 pub dry_run: bool,
77 pub now_unix: i64,
79 pub threads: Option<usize>,
81}
82
83impl OffloadOptions {
84 #[must_use]
86 pub fn new(remote: impl Into<String>, now_unix: i64) -> Self {
87 Self {
88 remote: remote.into(),
89 filter: None,
90 lop_git_dir: None,
91 configure_lop: false,
92 dry_run: false,
93 now_unix,
94 threads: None,
95 }
96 }
97}
98
99#[derive(Debug, thiserror::Error)]
101#[non_exhaustive]
102pub enum OffloadError {
103 #[error("remote '{remote}' has no url configured")]
105 RemoteNotConfigured {
106 remote: String,
108 },
109 #[error("remote '{remote}' is not a local repository ({url}); give its git directory")]
112 LopNotLocal {
113 remote: String,
115 url: String,
117 },
118 #[error("{} is not a git repository", path.display())]
120 LopNotARepository {
121 path: PathBuf,
123 },
124 #[error("the large-object promisor is this repository itself")]
127 LopIsMainRepository,
128 #[error(
130 "object formats differ: this repository uses {}, the promisor {}",
131 main.name(),
132 lop.name()
133 )]
134 HashAlgoMismatch {
135 main: HashAlgo,
137 lop: HashAlgo,
139 },
140 #[error("no size threshold: give a filter or set remote.{remote}.partialCloneFilter")]
142 NoThreshold {
143 remote: String,
145 },
146 #[error("blob {oid} is not in the promisor after copying it there")]
149 NotInLopAfterCopy {
150 oid: ObjectId,
152 },
153 #[error(
163 "{oid} is only in kept pack {}, and {referrer} names it",
164 pack.display()
165 )]
166 KeptPackObjectPromised {
167 pack: PathBuf,
169 oid: ObjectId,
171 referrer: ObjectId,
173 },
174 #[error("maintenance is already running on this repository")]
176 MaintenanceRunning {
177 holder: GcLockHolder,
179 },
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum OffloadRepo {
186 Main,
188 Lop,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194#[non_exhaustive]
195pub struct OffloadConfigChange {
196 pub repo: OffloadRepo,
198 pub key: String,
200 pub value: String,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206#[non_exhaustive]
207pub struct LopReadiness {
208 pub allow_filter: bool,
210 pub allow_any_sha1_in_want: bool,
212}
213
214impl LopReadiness {
215 #[must_use]
217 pub fn is_ready(&self) -> bool {
218 self.allow_filter && self.allow_any_sha1_in_want
219 }
220
221 fn from_config(config: &ConfigSet) -> Self {
222 let flag = |key: &str| config.bool_or(key, false);
223 Self {
224 allow_filter: flag("uploadpack.allowfilter"),
225 allow_any_sha1_in_want: flag("uploadpack.allowanysha1inwant"),
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
232#[non_exhaustive]
233pub struct OffloadReport {
234 pub remote: String,
236 pub lop_git_dir: PathBuf,
238 pub filter: ObjectFilterSpec,
240 pub dry_run: bool,
242 pub candidates: usize,
244 pub candidate_bytes: u64,
246 pub copied_to_lop: usize,
248 pub already_in_lop: usize,
254 pub dropped_from_main: usize,
256 pub skipped_checked_out: usize,
259 pub skipped_kept_pack: usize,
262 pub lop_pack: Option<OffloadPack>,
264 pub main_pack: Option<OffloadPack>,
267 pub main_bytes_before: u64,
269 pub main_bytes_after: u64,
271 pub config_changes: Vec<OffloadConfigChange>,
273 pub lop_ready: LopReadiness,
275 pub advertised: bool,
278}
279
280impl OffloadReport {
281 pub fn packs(&self) -> impl Iterator<Item = &OffloadPack> {
284 [&self.lop_pack, &self.main_pack].into_iter().flatten()
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290#[non_exhaustive]
291pub enum OffloadPackRole {
292 Lop,
294 MainPromisor,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
300#[non_exhaustive]
301pub struct OffloadPack {
302 pub role: OffloadPackRole,
304 pub path: PathBuf,
306 pub objects: usize,
308 pub bytes: u64,
310}
311
312impl OffloadPack {
313 fn written(role: OffloadPackRole, path: PathBuf, objects: usize) -> Result<Self> {
315 let bytes = std::fs::metadata(&path)?.len();
316 Ok(Self {
317 role,
318 path,
319 objects,
320 bytes,
321 })
322 }
323}
324
325pub fn offload_blobs(repo: &Repository, opts: &OffloadOptions) -> Result<OffloadReport> {
376 let odb = repo.odb();
377 odb.require_files_primary("offload")?;
378 let config = repo.config()?;
379 let remote = opts.remote.as_str();
380 let url = config
381 .get(&format!("remote.{remote}.url"))
382 .filter(|u| !u.trim().is_empty())
383 .ok_or_else(|| OffloadError::RemoteNotConfigured {
384 remote: remote.to_owned(),
385 })?;
386 let filter = opts
387 .filter
388 .or_else(|| stored_remote_filter(&config, remote))
389 .ok_or_else(|| OffloadError::NoThreshold {
390 remote: remote.to_owned(),
391 })?;
392 let lop = open_lop(repo, opts, &url)?;
393
394 let _lock = if opts.dry_run {
395 None
396 } else {
397 match GcLock::try_acquire(repo, opts.now_unix)? {
398 GcLockAttempt::Acquired(lock) => Some(lock),
399 GcLockAttempt::Held(holder) => {
400 return Err(OffloadError::MaintenanceRunning { holder }.into())
401 }
402 }
403 };
404
405 let main_bytes_before = local_object_bytes(odb)?;
406 let selection = select_candidates(repo, filter)?;
407 let candidates: Vec<ObjectId> = selection.candidates.keys().copied().collect();
408 if let Some(refusal) = kept_object_the_promisor_pack_names(odb, &selection.candidates)? {
409 return Err(refusal.into());
410 }
411 let in_lop = held_in_lop_keep_packs(lop.odb(), &candidates)?;
412 let missing_in_lop: Vec<ObjectId> = candidates
413 .iter()
414 .zip(&in_lop)
415 .filter_map(|(oid, has)| (!has).then_some(*oid))
416 .collect();
417
418 let mut report = OffloadReport {
419 remote: remote.to_owned(),
420 lop_git_dir: lop.git_dir().to_path_buf(),
421 filter,
422 dry_run: opts.dry_run,
423 candidates: candidates.len(),
424 candidate_bytes: selection.candidates.values().sum(),
425 copied_to_lop: 0,
426 already_in_lop: candidates.len() - missing_in_lop.len(),
427 dropped_from_main: 0,
428 skipped_checked_out: selection.skipped_checked_out,
429 skipped_kept_pack: selection.skipped_kept_pack,
430 lop_pack: None,
431 main_pack: None,
432 main_bytes_before,
433 main_bytes_after: main_bytes_before,
434 config_changes: Vec::new(),
435 lop_ready: LopReadiness::from_config(&lop_config(&lop)?),
436 advertised: config.bool_or("promisor.advertise", false),
437 };
438 if opts.dry_run {
439 return Ok(report);
440 }
441
442 if !missing_in_lop.is_empty() {
443 report.lop_pack = copy_to_lop(repo, &lop, &missing_in_lop, opts)?
444 .map(|(path, objects)| OffloadPack::written(OffloadPackRole::Lop, path, objects))
445 .transpose()?;
446 report.copied_to_lop = missing_in_lop.len();
447 }
448 lop.odb().invalidate_packs();
449 let present = held_in_lop_keep_packs(lop.odb(), &candidates)?;
450 if let Some((oid, _)) = candidates.iter().zip(&present).find(|(_, has)| !**has) {
451 return Err(OffloadError::NotInLopAfterCopy { oid: *oid }.into());
452 }
453 if let Some(pack) = &report.lop_pack {
454 crate::index_pack::sync_installed_pack(&pack.path)?;
456 }
457
458 let mut wanted: Vec<(String, String)> = Vec::new();
459 let promisor_key = format!("remote.{remote}.promisor");
460 if !config.bool_or(&promisor_key, false) {
461 wanted.push((promisor_key, "true".to_owned()));
462 }
463 let filter_key = format!("remote.{remote}.partialCloneFilter");
464 if opts.filter.is_some() && config.get(&filter_key).is_none() {
465 wanted.push((filter_key, filter.to_config_value()));
466 }
467 let bitmaps_key = "repack.writeBitmaps";
471 if config.bool_or(bitmaps_key, true) {
472 wanted.push((bitmaps_key.to_owned(), "false".to_owned()));
473 }
474 let mut added: Vec<(String, String)> = Vec::new();
477 let lop_dir = lop_dir_value(lop.git_dir());
478 if !config
479 .get_all(LOP_GIT_DIR_KEY)
480 .iter()
481 .any(|v| same_dir(Path::new(v), lop.git_dir()))
482 {
483 added.push((LOP_GIT_DIR_KEY.to_owned(), lop_dir));
484 }
485 write_config(repo.git_dir(), &wanted, &added)?;
486 report
487 .config_changes
488 .extend(
489 wanted
490 .into_iter()
491 .chain(added)
492 .map(|(key, value)| OffloadConfigChange {
493 repo: OffloadRepo::Main,
494 key,
495 value,
496 }),
497 );
498 if opts.configure_lop {
499 let lop_changes: Vec<(String, String)> = [
500 ("uploadpack.allowFilter", report.lop_ready.allow_filter),
501 (
502 "uploadpack.allowAnySHA1InWant",
503 report.lop_ready.allow_any_sha1_in_want,
504 ),
505 ]
506 .into_iter()
507 .filter(|(_, already)| !already)
508 .map(|(key, _)| (key.to_owned(), "true".to_owned()))
509 .collect();
510 write_config(lop.git_dir(), &lop_changes, &[])?;
511 report
512 .config_changes
513 .extend(
514 lop_changes
515 .into_iter()
516 .map(|(key, value)| OffloadConfigChange {
517 repo: OffloadRepo::Lop,
518 key,
519 value,
520 }),
521 );
522 report.lop_ready = LopReadiness::from_config(&lop_config(&lop)?);
523 }
524
525 let omit: OidHashSet = candidates.iter().copied().collect();
526 let repack = RepackOptions {
527 threads: opts.threads,
528 environment: repo.environment().clone(),
529 ..RepackOptions::default()
530 };
531 let rewrite = crate::maintenance::rewrite_local_objects_without(
535 repo,
536 &omit,
537 &repack,
538 crate::maintenance::PromisorRewriteWhen::Always,
539 )?;
540 report.main_pack = rewrite
541 .pack_path
542 .map(|path| OffloadPack::written(OffloadPackRole::MainPromisor, path, rewrite.objects))
543 .transpose()?;
544 report.dropped_from_main = odb.missing_stored_oids(candidates.iter().copied())?.len();
545 report.main_bytes_after = local_object_bytes(odb)?;
546 Ok(report)
547}
548
549fn open_lop(repo: &Repository, opts: &OffloadOptions, url: &str) -> Result<Repository> {
552 let path = match &opts.lop_git_dir {
553 Some(path) => path.clone(),
554 None if crate::transport_path::is_local_path_remote_url(url) => {
555 crate::transport_path::resolve_local_remote_git_dir(
556 url,
557 repo.git_dir(),
558 repo.work_tree(),
559 )
560 }
561 None => {
562 return Err(OffloadError::LopNotLocal {
563 remote: opts.remote.clone(),
564 url: crate::transport_path::redact_url_for_display(url),
565 }
566 .into())
567 }
568 };
569 let lop = Repository::open(
570 &path,
571 &RepositoryOptions::with_environment(repo.environment().clone()),
572 )
573 .map_err(|_| OffloadError::LopNotARepository { path: path.clone() })?;
574 if same_object_store(repo, &lop) {
575 return Err(OffloadError::LopIsMainRepository.into());
576 }
577 let (main_algo, lop_algo) = (repo.odb().hash_algo(), lop.odb().hash_algo());
578 if main_algo != lop_algo {
579 return Err(OffloadError::HashAlgoMismatch {
580 main: main_algo,
581 lop: lop_algo,
582 }
583 .into());
584 }
585 lop.odb().require_files_primary("offload")?;
586 Ok(lop)
587}
588
589fn lop_config(lop: &Repository) -> Result<ConfigSet> {
591 ConfigSet::load(lop.environment(), Some(lop.git_dir()), true)
592}
593
594struct Selection {
596 candidates: OidHashMap<u64>,
597 skipped_checked_out: usize,
598 skipped_kept_pack: usize,
599}
600
601fn select_candidates(repo: &Repository, filter: ObjectFilterSpec) -> Result<Selection> {
608 let odb = repo.odb();
609 let pack_dir = odb.objects_dir().join("pack");
610 let (kept, rewritten): (Vec<_>, Vec<_>) = odb
611 .local_pack_indexes()?
612 .into_iter()
613 .partition(|idx| pack_dir.join(keep_name(&idx.pack_path)).is_file());
614 let in_any = |indexes: &[std::sync::Arc<PackIndex>], oid: &ObjectId| {
615 indexes.iter().any(|idx| idx.find_position(oid).is_some())
616 };
617 let will_be_rewritten =
618 |oid: &ObjectId| in_any(&rewritten, oid) || odb.local_loose_file_size(oid).is_some();
619 let checked_out = crate::promisor::blobs_needed_by_checkouts(repo)?;
620
621 let mut selection = Selection {
622 candidates: crate::objects::oid_hash_map(),
623 skipped_checked_out: 0,
624 skipped_kept_pack: 0,
625 };
626 let mut unanchored: OidHashMap<u64> = crate::objects::oid_hash_map();
628 let mut blobs_seen = oid_hash_set();
629 let mut walked = oid_hash_set();
630 let mut queue: Vec<ObjectId> = crate::maintenance::collect_maintenance_object_roots(repo)?;
631 let oid_len = odb.hash_algo().len();
632 while let Some(oid) = queue.pop() {
633 if !walked.insert(oid) {
634 continue;
635 }
636 let Some(obj) = odb.try_read_once(&oid)? else {
637 continue;
638 };
639 match obj.kind {
640 ObjectKind::Commit => {
641 let commit = CommitRef::parse(&obj.data)?;
642 queue.push(commit.tree());
643 queue.extend(commit.parents().iter().copied());
644 }
645 ObjectKind::Tag => {
646 if let Some(target) = crate::objects::tag_object_line_oid(&obj.data) {
647 queue.push(target);
648 }
649 }
650 ObjectKind::Tree => {
651 let anchors = will_be_rewritten(&oid);
652 for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
653 let entry = entry?;
654 match entry.mode & 0o170000 {
655 0o100000 | 0o120000 => {}
656 0o040000 => {
657 queue.push(entry.oid);
658 continue;
659 }
660 _ => continue,
662 }
663 if anchors {
664 if let Some(size) = unanchored.remove(&entry.oid) {
665 selection.candidates.insert(entry.oid, size);
666 }
667 }
668 if !blobs_seen.insert(entry.oid) {
669 continue;
670 }
671 let Some(size) = selected_blob_size(odb, filter, &entry.oid)? else {
672 continue;
673 };
674 if checked_out.contains(&entry.oid) {
675 selection.skipped_checked_out += 1;
676 } else if in_any(&kept, &entry.oid) {
677 selection.skipped_kept_pack += 1;
678 } else if anchors {
679 selection.candidates.insert(entry.oid, size);
680 } else {
681 unanchored.insert(entry.oid, size);
682 }
683 }
684 }
685 ObjectKind::Blob => {}
686 }
687 }
688 selection.skipped_kept_pack += unanchored.len();
689 Ok(selection)
690}
691
692fn kept_object_the_promisor_pack_names(
699 odb: &Odb,
700 omit: &OidHashMap<u64>,
701) -> Result<Option<OffloadError>> {
702 odb.reprepare_local_packs();
703 let (kept, rewritten): (Vec<_>, Vec<_>) = odb
704 .local_pack_indexes()?
705 .into_iter()
706 .partition(|idx| idx.pack_path.with_extension("keep").is_file());
707 let (kept_promisor, kept_ordinary): (Vec<_>, Vec<_>) =
708 kept.into_iter().partition(|idx| idx.is_promisor);
709 if kept_ordinary.is_empty() {
710 return Ok(None);
711 }
712 let loose: OidHashSet = odb
713 .enumerate_local_loose_objects()?
714 .into_iter()
715 .map(|(oid, _)| oid)
716 .collect();
717 let in_promisor_pack_after = |oid: &ObjectId| {
718 loose.contains(oid)
719 || [&rewritten, &kept_promisor]
720 .into_iter()
721 .flatten()
722 .any(|idx| idx.find_position(oid).is_some())
723 };
724 let mut only_kept: OidHashMap<usize> = crate::objects::oid_hash_map();
725 for (pack_no, idx) in kept_ordinary.iter().enumerate() {
726 for entry in idx.iter() {
727 let oid = ObjectId::from_bytes(entry.oid())?;
728 if !in_promisor_pack_after(&oid) {
729 only_kept.entry(oid).or_insert(pack_no);
730 }
731 }
732 }
733 if only_kept.is_empty() {
734 return Ok(None);
735 }
736 let mut referrers: Vec<ObjectId> = loose.iter().copied().collect();
737 for idx in &rewritten {
738 for entry in idx.iter() {
739 referrers.push(ObjectId::from_bytes(entry.oid())?);
740 }
741 }
742 let oid_len = odb.hash_algo().len();
743 let mut checked = oid_hash_set();
744 for referrer in referrers {
745 if omit.contains_key(&referrer) || !checked.insert(referrer) {
746 continue;
747 }
748 let kind = match odb.read_info_without_fetch(&referrer) {
749 Ok(info) => info.kind,
750 Err(Error::ObjectNotFound(_)) => continue,
751 Err(err) => return Err(err),
752 };
753 if kind == ObjectKind::Blob {
754 continue;
755 }
756 let obj = odb.read(&referrer)?;
757 let mut named: Vec<ObjectId> = Vec::new();
758 match kind {
759 ObjectKind::Commit => {
760 let commit = CommitRef::parse(&obj.data)?;
761 named.push(commit.tree());
762 named.extend(commit.parents().iter().copied());
763 }
764 ObjectKind::Tree => {
765 for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
766 let entry = entry?;
767 if entry.mode & 0o170000 != 0o160000 {
768 named.push(entry.oid);
769 }
770 }
771 }
772 ObjectKind::Tag => named.extend(crate::objects::tag_object_line_oid(&obj.data)),
773 ObjectKind::Blob => {}
774 }
775 if let Some((oid, &pack_no)) = named.iter().find_map(|oid| only_kept.get_key_value(oid)) {
776 return Ok(Some(OffloadError::KeptPackObjectPromised {
777 pack: kept_ordinary[pack_no].pack_path.clone(),
778 oid: *oid,
779 referrer,
780 }));
781 }
782 }
783 Ok(None)
784}
785
786fn selected_blob_size(odb: &Odb, filter: ObjectFilterSpec, oid: &ObjectId) -> Result<Option<u64>> {
788 if !odb.try_exists_stored(oid)? {
789 return Ok(None);
790 }
791 let info = match odb.read_info_without_fetch(oid) {
792 Ok(info) => info,
793 Err(Error::ObjectNotFound(_)) => return Ok(None),
794 Err(err) => return Err(err),
795 };
796 if info.kind != ObjectKind::Blob {
797 return Ok(None);
798 }
799 let selected = match filter {
800 ObjectFilterSpec::BlobNone => true,
801 ObjectFilterSpec::BlobLimit(limit) => info.size >= limit,
802 };
803 Ok(selected.then_some(info.size))
804}
805
806fn keep_name(pack_path: &Path) -> PathBuf {
807 PathBuf::from(pack_path.file_name().unwrap_or_default()).with_extension("keep")
808}
809
810fn copy_to_lop(
813 repo: &Repository,
814 lop: &Repository,
815 blobs: &[ObjectId],
816 opts: &OffloadOptions,
817) -> Result<Option<(PathBuf, usize)>> {
818 let cfg = repo.config().ok();
819 let pack_opts = PackBuildOptions {
820 delta: false,
821 window: 0,
822 max_depth: 0,
823 reuse_deltas: false,
824 reuse_objects: true,
825 use_bitmaps: false,
826 thin: false,
827 ..PackBuildOptions::for_local_copy(cfg.as_deref())
828 };
829 let lop_pack_dir = lop.odb().objects_dir().join("pack");
830 let built = crate::maintenance::write_staged_repack_pack(
831 repo.odb(),
832 &lop_pack_dir,
833 blobs,
834 &std::collections::HashMap::new(),
835 &pack_opts,
836 opts.threads,
837 cfg.as_deref(),
838 )?;
839 let Some(staging) = built.staging_path.as_ref() else {
840 return Ok(None);
841 };
842 let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
843 let ingested = install_pack_path(
844 staging,
845 lop.odb(),
846 &IngestPackOptions {
847 threads: opts.threads,
848 environment: lop.environment().clone(),
849 skip_post_index_verify: trusted_rows.is_some(),
850 trusted_index_rows: trusted_rows,
851 keep_message: Some(LOP_KEEP_MESSAGE.to_owned()),
852 ..IngestPackOptions::default()
853 },
854 )?;
855 let objects = ingested.object_ids.len();
856 Ok(ingested.pack_path.map(|path| (path, objects)))
857}
858
859fn held_in_lop_keep_packs(lop: &Odb, oids: &[ObjectId]) -> Result<Vec<bool>> {
862 let kept = lop_keep_packs(lop)?;
863 Ok(oids
864 .iter()
865 .map(|oid| kept.iter().any(|idx| idx.find_position(oid).is_some()))
866 .collect())
867}
868
869pub(crate) fn lop_keep_packs(lop: &Odb) -> Result<Vec<std::sync::Arc<PackIndex>>> {
883 Ok(lop
884 .local_pack_indexes()?
885 .into_iter()
886 .filter(|idx| has_lop_keep_marker(&idx.pack_path))
887 .collect())
888}
889
890fn has_lop_keep_marker(pack_path: &Path) -> bool {
892 std::fs::read(pack_path.with_extension("keep"))
893 .is_ok_and(|contents| contents.trim_ascii_end() == LOP_KEEP_MESSAGE.as_bytes())
894}
895
896fn write_config(
899 git_dir: &Path,
900 changes: &[(String, String)],
901 additions: &[(String, String)],
902) -> Result<()> {
903 if changes.is_empty() && additions.is_empty() {
904 return Ok(());
905 }
906 let path = git_dir.join("config");
907 let content = match std::fs::read_to_string(&path) {
908 Ok(content) => content,
909 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
910 Err(err) => return Err(Error::Io(err)),
911 };
912 let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
913 for (key, value) in changes {
914 config.set(key, value)?;
915 }
916 for (key, value) in additions {
917 config.add_value(key, value)?;
918 }
919 config.write()
920}
921
922fn lop_dir_value(git_dir: &Path) -> String {
925 use crate::git_path::CanonicalizeExt as _;
926 git_dir
927 .canonicalize_simplified()
928 .unwrap_or_else(|_| git_dir.to_path_buf())
929 .to_string_lossy()
930 .into_owned()
931}
932
933pub(crate) fn same_object_store(a: &Repository, b: &Repository) -> bool {
936 same_dir(a.odb().objects_dir(), b.odb().objects_dir())
937}
938
939pub(crate) fn same_dir(a: &Path, b: &Path) -> bool {
941 use crate::git_path::CanonicalizeExt as _;
942 match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
943 (Ok(a), Ok(b)) => a == b,
944 _ => a == b,
945 }
946}
947
948fn local_object_bytes(odb: &Odb) -> Result<u64> {
950 odb.invalidate_packs();
951 let mut total = 0u64;
952 for idx in odb.local_pack_indexes()? {
953 for path in [&idx.pack_path, &idx.idx_path] {
954 total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
955 }
956 }
957 for (_, path) in odb.enumerate_local_loose_objects()? {
958 total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
959 }
960 Ok(total)
961}
962
963#[cfg(test)]
964#[allow(clippy::unwrap_used, clippy::expect_used)]
965mod tests {
966 use super::*;
967 use crate::repo::InitOptions;
968
969 const NOW: i64 = 1_700_000_000;
970
971 struct Fixture {
974 dir: tempfile::TempDir,
975 blobs: Vec<ObjectId>,
976 }
977
978 fn blob_data(seed: usize, size: usize) -> Vec<u8> {
980 let mut state = (seed as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
981 (0..size)
982 .map(|_| {
983 state ^= state << 13;
984 state ^= state >> 7;
985 state ^= state << 17;
986 state.to_le_bytes()[0]
987 })
988 .collect()
989 }
990
991 impl Fixture {
992 fn new(sizes: &[usize]) -> Self {
993 let dir = tempfile::tempdir().unwrap();
994 let init = InitOptions::new()
995 .with_bare(true)
996 .with_initial_branch("main");
997 let main = Repository::init(&dir.path().join("main.git"), &init).unwrap();
998 Repository::init(&dir.path().join("lop.git"), &init).unwrap();
999 let odb = main.odb();
1000 let blobs: Vec<ObjectId> = sizes
1001 .iter()
1002 .enumerate()
1003 .map(|(i, &size)| odb.write(ObjectKind::Blob, &blob_data(i, size)).unwrap())
1004 .collect();
1005 let mut entries: Vec<crate::objects::TreeEntry> = blobs
1006 .iter()
1007 .enumerate()
1008 .map(|(i, oid)| crate::objects::TreeEntry {
1009 mode: 0o100644,
1010 name: format!("f{i}").into_bytes(),
1011 oid: *oid,
1012 })
1013 .collect();
1014 entries.sort_by(|a, b| a.name.cmp(&b.name));
1015 let tree = odb
1016 .write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
1017 .unwrap();
1018 let commit =
1019 format!("tree {tree}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nseed\n");
1020 let commit = odb.write(ObjectKind::Commit, commit.as_bytes()).unwrap();
1021 std::fs::write(
1022 dir.path().join("main.git/refs/heads/main"),
1023 format!("{commit}\n"),
1024 )
1025 .unwrap();
1026 let fx = Self { dir, blobs };
1027 fx.append_main_config("[remote \"lop\"]\n\turl = ../lop.git\n");
1028 fx
1029 }
1030
1031 fn main_dir(&self) -> PathBuf {
1032 self.dir.path().join("main.git")
1033 }
1034
1035 fn lop_dir(&self) -> PathBuf {
1036 self.dir.path().join("lop.git")
1037 }
1038
1039 fn main(&self) -> Repository {
1040 Repository::open(&self.main_dir(), &RepositoryOptions::empty()).unwrap()
1041 }
1042
1043 fn lop(&self) -> Repository {
1044 Repository::open(&self.lop_dir(), &RepositoryOptions::empty()).unwrap()
1045 }
1046
1047 fn append_main_config(&self, text: &str) {
1048 let path = self.main_dir().join("config");
1049 let mut config = std::fs::read_to_string(&path).unwrap();
1050 config.push_str(text);
1051 std::fs::write(path, config).unwrap();
1052 }
1053
1054 fn offload(&self, limit: u64) -> OffloadReport {
1055 let mut opts = OffloadOptions::new("lop", NOW);
1056 opts.filter = Some(ObjectFilterSpec::BlobLimit(limit));
1057 offload_blobs(&self.main(), &opts).expect("offload")
1058 }
1059
1060 fn files(&self, dir: &Path, ext: &str) -> Vec<PathBuf> {
1061 std::fs::read_dir(dir.join("objects/pack"))
1062 .map(|entries| {
1063 entries
1064 .flatten()
1065 .map(|e| e.path())
1066 .filter(|p| p.extension().is_some_and(|e| e == ext))
1067 .collect()
1068 })
1069 .unwrap_or_default()
1070 }
1071 }
1072
1073 #[test]
1074 fn blobs_at_the_limit_move_and_smaller_ones_stay() {
1075 let fx = Fixture::new(&[10_000, 9_999, 30_000]);
1076 let report = fx.offload(10_000);
1077 assert_eq!(report.candidates, 2);
1078 assert_eq!(report.candidate_bytes, 40_000);
1079 assert_eq!(report.copied_to_lop, 2);
1080 assert_eq!(report.dropped_from_main, 2);
1081 assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(10_000));
1082
1083 let main = fx.main();
1084 let lop = fx.lop();
1085 for (i, moved) in [(0, true), (1, false), (2, true)] {
1086 let blob = fx.blobs[i];
1087 assert_eq!(!main.odb().exists_stored(&blob), moved, "main f{i}");
1088 assert_eq!(lop.odb().exists_stored(&blob), moved, "lop f{i}");
1089 }
1090 let promisor = fx.files(&fx.main_dir(), "promisor");
1093 assert_eq!(promisor.len(), 1);
1094 assert_eq!(
1095 report.main_pack.as_ref().and_then(|p| p.path.file_name()),
1096 promisor[0].with_extension("pack").file_name()
1097 );
1098 let indexes = main.odb().local_pack_indexes().unwrap();
1099 let promisor_idx = indexes
1100 .iter()
1101 .find(|i| i.is_promisor)
1102 .expect("promisor pack");
1103 assert_eq!(indexes.len(), 1);
1104 assert_eq!(promisor_idx.len(), 3, "the commit, its tree and f1");
1105 assert!(promisor_idx.find_position(&fx.blobs[1]).is_some());
1106 assert!(main
1107 .odb()
1108 .enumerate_local_loose_objects()
1109 .unwrap()
1110 .is_empty());
1111 assert!(report.main_bytes_after < report.main_bytes_before);
1112
1113 let roles: Vec<_> = report.packs().map(|p| (p.role, p.objects)).collect();
1115 assert_eq!(
1116 roles,
1117 [
1118 (OffloadPackRole::Lop, 2),
1119 (OffloadPackRole::MainPromisor, 3),
1120 ]
1121 );
1122 for pack in report.packs() {
1123 assert_eq!(pack.bytes, std::fs::metadata(&pack.path).unwrap().len());
1124 }
1125 assert!(report
1126 .lop_pack
1127 .as_ref()
1128 .unwrap()
1129 .path
1130 .starts_with(fx.lop().git_dir()));
1131 }
1132
1133 #[test]
1134 fn lop_pack_is_kept_and_complete() {
1135 let fx = Fixture::new(&[4096]);
1136 let report = fx.offload(1024);
1137 let lop_pack = report.lop_pack.expect("lop pack").path;
1138 assert_eq!(
1139 std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap(),
1140 format!("{LOP_KEEP_MESSAGE}\n")
1141 );
1142 let blob = fx.lop().odb().read(&fx.blobs[0]).unwrap();
1143 assert_eq!(blob.data, blob_data(0, 4096));
1144 }
1145
1146 #[test]
1147 fn config_is_set_in_main_and_optionally_in_the_lop() {
1148 let fx = Fixture::new(&[2048]);
1149 let mut opts = OffloadOptions::new("lop", NOW);
1150 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1151 opts.configure_lop = true;
1152 let report = offload_blobs(&fx.main(), &opts).unwrap();
1153 let lop_dir = lop_dir_value(fx.lop().git_dir());
1154 assert!(Path::new(&lop_dir).is_absolute());
1155 let keys: Vec<(OffloadRepo, &str, &str)> = report
1156 .config_changes
1157 .iter()
1158 .map(|c| (c.repo, c.key.as_str(), c.value.as_str()))
1159 .collect();
1160 assert_eq!(
1161 keys,
1162 [
1163 (OffloadRepo::Main, "remote.lop.promisor", "true"),
1164 (
1165 OffloadRepo::Main,
1166 "remote.lop.partialCloneFilter",
1167 "blob:limit=1024"
1168 ),
1169 (OffloadRepo::Main, "repack.writeBitmaps", "false"),
1170 (OffloadRepo::Main, LOP_GIT_DIR_KEY, lop_dir.as_str()),
1171 (OffloadRepo::Lop, "uploadpack.allowFilter", "true"),
1172 (OffloadRepo::Lop, "uploadpack.allowAnySHA1InWant", "true"),
1173 ]
1174 );
1175 assert!(report.lop_ready.is_ready());
1176 assert!(!report.advertised);
1177 let config = fx.main().config().unwrap();
1178 assert_eq!(config.get("remote.lop.promisor").as_deref(), Some("true"));
1179 assert!(config.get("extensions.partialclone").is_none());
1180 assert_eq!(
1181 crate::partial_clone::stored_remote_filter(&config, "lop"),
1182 Some(ObjectFilterSpec::BlobLimit(1024))
1183 );
1184 assert_eq!(config.get_all(LOP_GIT_DIR_KEY), [lop_dir.clone()]);
1185
1186 let again = offload_blobs(&fx.main(), &opts).unwrap();
1188 assert!(
1189 again.config_changes.is_empty(),
1190 "{:?}",
1191 again.config_changes
1192 );
1193 assert_eq!(
1194 fx.main().config().unwrap().get_all(LOP_GIT_DIR_KEY),
1195 [lop_dir]
1196 );
1197 }
1198
1199 #[test]
1200 fn stored_filter_is_used_and_lop_is_left_alone_by_default() {
1201 let fx = Fixture::new(&[2048, 10]);
1202 fx.append_main_config("\tpartialCloneFilter = blob:limit=1k\n");
1203 let report = offload_blobs(&fx.main(), &OffloadOptions::new("lop", NOW)).unwrap();
1204 assert_eq!(report.candidates, 1);
1205 assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(1024));
1206 let keys: Vec<&str> = report
1207 .config_changes
1208 .iter()
1209 .map(|c| c.key.as_str())
1210 .collect();
1211 assert_eq!(
1212 keys,
1213 [
1214 "remote.lop.promisor",
1215 "repack.writeBitmaps",
1216 LOP_GIT_DIR_KEY
1217 ]
1218 );
1219 assert_eq!(report.lop_ready, LopReadiness::default());
1220 }
1221
1222 fn tree_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
1224 let mut out = Vec::new();
1225 let mut stack = vec![dir.to_path_buf()];
1226 while let Some(path) = stack.pop() {
1227 if path.is_dir() {
1228 stack.extend(
1229 std::fs::read_dir(&path)
1230 .unwrap()
1231 .flatten()
1232 .map(|e| e.path()),
1233 );
1234 } else {
1235 out.push((path.clone(), std::fs::read(&path).unwrap()));
1236 }
1237 }
1238 out.sort();
1239 out
1240 }
1241
1242 #[test]
1243 fn dry_run_writes_nothing() {
1244 let fx = Fixture::new(&[2048]);
1245 let before = tree_snapshot(&fx.main_dir());
1246 let mut opts = OffloadOptions::new("lop", NOW);
1247 opts.filter = Some(ObjectFilterSpec::BlobNone);
1248 opts.dry_run = true;
1249 opts.configure_lop = true;
1250 let report = offload_blobs(&fx.main(), &opts).unwrap();
1251 assert!(report.dry_run);
1252 assert_eq!(report.candidates, 1);
1253 assert_eq!(report.copied_to_lop, 0);
1254 assert!(report.config_changes.is_empty());
1255 assert_eq!(tree_snapshot(&fx.main_dir()), before);
1256 assert!(fx.files(&fx.lop_dir(), "pack").is_empty());
1257 assert!(!fx.main_dir().join("gc.pid").exists());
1258 }
1259
1260 #[test]
1261 fn running_again_is_a_no_op_until_new_blobs_arrive() {
1262 let fx = Fixture::new(&[2048, 10]);
1263 fx.offload(1024);
1264 let again = fx.offload(1024);
1265 assert_eq!(again.candidates, 0);
1266 assert_eq!(again.copied_to_lop, 0);
1267 assert!(again.main_pack.is_none(), "nothing to rewrite");
1268 assert_eq!(fx.files(&fx.lop_dir(), "pack").len(), 1);
1269 assert_eq!(fx.files(&fx.main_dir(), "pack").len(), 1);
1270 }
1271
1272 fn pack_into_lop(fx: &Fixture, message: &str) {
1274 let pack = crate::pack_objects::build_pack_from_send_list(
1275 fx.main().odb(),
1276 &[fx.blobs[0]],
1277 &std::collections::HashSet::new(),
1278 &PackBuildOptions::default(),
1279 &std::collections::HashMap::new(),
1280 )
1281 .unwrap();
1282 crate::index_pack::install_pack_bytes(
1283 pack,
1284 fx.lop().odb(),
1285 &IngestPackOptions {
1286 keep_message: Some(message.to_owned()),
1287 ..IngestPackOptions::default()
1288 },
1289 )
1290 .unwrap();
1291 }
1292
1293 #[test]
1294 fn blobs_the_lop_keeps_are_not_copied_again() {
1295 let fx = Fixture::new(&[2048]);
1296 pack_into_lop(&fx, LOP_KEEP_MESSAGE);
1297 let report = fx.offload(1024);
1298 assert_eq!(report.already_in_lop, 1);
1299 assert_eq!(report.copied_to_lop, 0);
1300 assert!(report.lop_pack.is_none());
1301 assert_eq!(report.dropped_from_main, 1);
1302 }
1303
1304 #[test]
1305 fn a_copy_another_writer_keeps_for_a_while_is_not_trusted() {
1306 for message in ["receive-pack 4242 on build-host", "pinned"] {
1309 let fx = Fixture::new(&[2048]);
1310 pack_into_lop(&fx, message);
1311 let report = fx.offload(1024);
1312 assert_eq!(report.already_in_lop, 0, "{message}");
1313 assert_eq!(report.copied_to_lop, 1, "{message}");
1314 let lop_pack = report.lop_pack.expect("copied").path;
1315 let keep = std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap();
1316 assert_eq!(keep.trim_end(), LOP_KEEP_MESSAGE);
1317 }
1318 }
1319
1320 #[cfg(unix)]
1321 #[test]
1322 fn a_lop_sharing_main_objects_is_refused() {
1323 let fx = Fixture::new(&[2048]);
1324 let lop_objects = fx.lop_dir().join("objects");
1325 std::fs::remove_dir_all(&lop_objects).unwrap();
1326 std::os::unix::fs::symlink(fx.main_dir().join("objects"), &lop_objects).unwrap();
1327 let mut opts = OffloadOptions::new("lop", NOW);
1328 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1329 assert!(matches!(
1330 offload_err(&fx, &opts),
1331 OffloadError::LopIsMainRepository
1332 ));
1333 assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1334 }
1335
1336 fn prune_lop_loose(fx: &Fixture) {
1339 for (_, path) in fx.lop().odb().enumerate_local_loose_objects().unwrap() {
1340 std::fs::remove_file(path).unwrap();
1341 }
1342 }
1343
1344 #[test]
1345 fn a_loose_copy_in_the_lop_is_not_trusted() {
1346 let fx = Fixture::new(&[2048]);
1347 fx.lop()
1348 .odb()
1349 .write(ObjectKind::Blob, &blob_data(0, 2048))
1350 .unwrap();
1351 let report = fx.offload(1024);
1352 assert_eq!(report.already_in_lop, 0);
1353 assert_eq!(report.copied_to_lop, 1);
1354 assert!(report.lop_pack.is_some());
1355 assert_eq!(report.dropped_from_main, 1);
1356 prune_lop_loose(&fx);
1357 let lop = fx.lop();
1358 assert_eq!(
1359 lop.odb().read(&fx.blobs[0]).unwrap().data,
1360 blob_data(0, 2048),
1361 "the kept pack survives the LOP's prune"
1362 );
1363 }
1364
1365 #[test]
1366 fn a_copy_the_lop_borrows_from_main_is_not_trusted() {
1367 let fx = Fixture::new(&[2048]);
1368 let alternates = fx.lop_dir().join("objects/info/alternates");
1369 std::fs::create_dir_all(alternates.parent().unwrap()).unwrap();
1370 std::fs::write(
1371 &alternates,
1372 format!("{}\n", fx.main_dir().join("objects").display()),
1373 )
1374 .unwrap();
1375 let report = fx.offload(1024);
1376 assert_eq!(report.already_in_lop, 0);
1377 assert_eq!(report.copied_to_lop, 1);
1378 std::fs::remove_file(&alternates).unwrap();
1379 assert_eq!(
1380 fx.lop().odb().read(&fx.blobs[0]).unwrap().data,
1381 blob_data(0, 2048),
1382 "the LOP has its own copy"
1383 );
1384 }
1385
1386 #[test]
1387 fn running_again_folds_pushed_objects_into_the_promisor_pack() {
1388 let fx = Fixture::new(&[2048, 10]);
1389 fx.offload(1024);
1390 let main = fx.main();
1392 let mut body = b"100644 big\0".to_vec();
1393 body.extend_from_slice(fx.blobs[0].as_bytes());
1394 let tree = main
1395 .odb()
1396 .write_loose_materialize(ObjectKind::Tree, &body)
1397 .unwrap();
1398 let again = fx.offload(1024);
1399 assert_eq!(again.candidates, 0);
1400 let pack = again.main_pack.expect("folded").path;
1401 let promisor: Vec<_> = fx
1402 .files(&fx.main_dir(), "promisor")
1403 .iter()
1404 .map(|p| p.file_name().map(|n| n.to_os_string()))
1405 .collect();
1406 assert_eq!(
1407 promisor,
1408 [pack
1409 .with_extension("promisor")
1410 .file_name()
1411 .map(|n| n.to_os_string())]
1412 );
1413 let main = fx.main();
1414 assert!(main
1415 .odb()
1416 .enumerate_local_loose_objects()
1417 .unwrap()
1418 .is_empty());
1419 assert!(main.odb().exists_stored(&tree));
1420 assert!(!main.odb().exists_stored(&fx.blobs[0]));
1421 }
1422
1423 #[test]
1424 fn blobs_in_kept_packs_stay() {
1425 let fx = Fixture::new(&[2048]);
1426 let main = fx.main();
1427 let pack = crate::pack_objects::build_pack_from_send_list(
1428 main.odb(),
1429 &[fx.blobs[0]],
1430 &std::collections::HashSet::new(),
1431 &PackBuildOptions::default(),
1432 &std::collections::HashMap::new(),
1433 )
1434 .unwrap();
1435 crate::index_pack::install_pack_bytes(
1436 pack,
1437 main.odb(),
1438 &IngestPackOptions {
1439 keep_message: Some("pinned".into()),
1440 ..IngestPackOptions::default()
1441 },
1442 )
1443 .unwrap();
1444 let report = fx.offload(1024);
1445 assert_eq!(report.candidates, 0);
1446 assert_eq!(report.skipped_kept_pack, 1);
1447 assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1448 }
1449
1450 fn keep_in_main(fx: &Fixture, oids: &[ObjectId]) -> PathBuf {
1453 let main = fx.main();
1454 let pack = crate::pack_objects::build_pack_from_send_list(
1455 main.odb(),
1456 oids,
1457 &std::collections::HashSet::new(),
1458 &PackBuildOptions::default(),
1459 &std::collections::HashMap::new(),
1460 )
1461 .unwrap();
1462 let installed = crate::index_pack::install_pack_bytes(
1463 pack,
1464 main.odb(),
1465 &IngestPackOptions {
1466 keep_message: Some("pinned".into()),
1467 ..IngestPackOptions::default()
1468 },
1469 )
1470 .unwrap();
1471 crate::prune_packed::prune_packed_objects(main.odb().objects_dir(), Default::default())
1472 .unwrap();
1473 installed.pack_path.unwrap()
1474 }
1475
1476 #[test]
1477 fn a_kept_object_the_promisor_pack_would_name_stops_offload() {
1478 let fx = Fixture::new(&[2048, 10]);
1481 let kept = keep_in_main(&fx, &[fx.blobs[1]]);
1482 let mut opts = OffloadOptions::new("lop", NOW);
1483 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1484 for dry_run in [true, false] {
1485 opts.dry_run = dry_run;
1486 match offload_err(&fx, &opts) {
1487 OffloadError::KeptPackObjectPromised { pack, oid, .. } => {
1488 assert_eq!(pack, kept);
1489 assert_eq!(oid, fx.blobs[1]);
1490 }
1491 other => panic!("got {other:?}"),
1492 }
1493 }
1494 assert!(fx.files(&fx.lop_dir(), "pack").is_empty(), "nothing copied");
1495 assert!(fx.files(&fx.main_dir(), "promisor").is_empty());
1496 assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1497
1498 std::fs::remove_file(kept.with_extension("keep")).unwrap();
1500 let report = fx.offload(1024);
1501 assert_eq!(report.dropped_from_main, 1);
1502 let main = fx.main();
1503 let indexes = main.odb().local_pack_indexes().unwrap();
1504 assert_eq!(indexes.len(), 1);
1505 assert!(indexes[0].is_promisor);
1506 assert!(indexes[0].find_position(&fx.blobs[1]).is_some());
1507 }
1508
1509 #[test]
1510 fn a_kept_pack_nothing_names_does_not_stop_offload() {
1511 let fx = Fixture::new(&[2048]);
1512 let stray = fx
1513 .main()
1514 .odb()
1515 .write(ObjectKind::Blob, b"named by nothing")
1516 .unwrap();
1517 let kept = keep_in_main(&fx, &[stray]);
1518 let report = fx.offload(1024);
1519 assert_eq!(report.dropped_from_main, 1);
1520 assert!(kept.is_file() && kept.with_extension("keep").is_file());
1521 assert!(fx.main().odb().exists_stored(&stray));
1522 }
1523
1524 #[test]
1525 fn blobs_a_checkout_needs_stay() {
1526 let dir = tempfile::tempdir().unwrap();
1527 let work = dir.path().join("work");
1528 let repo =
1529 Repository::init(&work, &InitOptions::new().with_initial_branch("main")).unwrap();
1530 Repository::init(
1531 &dir.path().join("lop.git"),
1532 &InitOptions::new().with_bare(true),
1533 )
1534 .unwrap();
1535 std::fs::write(work.join("big"), vec![b'x'; 4096]).unwrap();
1536 crate::porcelain::add::stage_for_commit(
1537 &repo,
1538 &mut crate::progress::NullProgress,
1539 &crate::progress::NeverCancel,
1540 )
1541 .unwrap();
1542 let ident = "T <t@example.com> 1 +0000".to_string();
1543 let mut req = crate::porcelain::commit::CommitRequest::new("big");
1544 let sig: crate::ident::Signature = ident.parse().unwrap();
1545 req.author = Some(sig.clone());
1546 req.committer = Some(sig);
1547 crate::porcelain::commit::create_commit(
1548 &repo,
1549 &req,
1550 &mut crate::progress::NullProgress,
1551 &crate::progress::NeverCancel,
1552 )
1553 .unwrap();
1554 let config = repo.git_dir().join("config");
1555 let mut text = std::fs::read_to_string(&config).unwrap();
1556 text.push_str(&format!(
1557 "[remote \"lop\"]\n\turl = {}\n",
1558 dir.path().join("lop.git").display()
1559 ));
1560 std::fs::write(&config, text).unwrap();
1561 let repo = Repository::discover(Some(&work), &RepositoryOptions::empty()).unwrap();
1562 let mut opts = OffloadOptions::new("lop", NOW);
1563 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1564 let report = offload_blobs(&repo, &opts).unwrap();
1565 assert_eq!(report.candidates, 0);
1566 assert_eq!(report.skipped_checked_out, 1);
1567 }
1568
1569 fn offload_err(fx: &Fixture, opts: &OffloadOptions) -> OffloadError {
1570 match offload_blobs(&fx.main(), opts) {
1571 Err(Error::Offload(err)) => err,
1572 other => panic!("expected an offload error, got {other:?}"),
1573 }
1574 }
1575
1576 #[test]
1577 fn refusals_are_typed() {
1578 let fx = Fixture::new(&[2048]);
1579 let with_filter = |remote: &str| {
1580 let mut opts = OffloadOptions::new(remote, NOW);
1581 opts.filter = Some(ObjectFilterSpec::BlobNone);
1582 opts
1583 };
1584 assert!(matches!(
1585 offload_err(&fx, &with_filter("nope")),
1586 OffloadError::RemoteNotConfigured { remote } if remote == "nope"
1587 ));
1588 assert!(matches!(
1589 offload_err(&fx, &OffloadOptions::new("lop", NOW)),
1590 OffloadError::NoThreshold { .. }
1591 ));
1592
1593 fx.append_main_config(
1594 "[remote \"web\"]\n\turl = https://user:secret@lop.example/r.git\n\
1595 [remote \"self\"]\n\turl = .\n\
1596 [remote \"gone\"]\n\turl = ../nothing-here.git\n",
1597 );
1598 match offload_err(&fx, &with_filter("web")) {
1599 OffloadError::LopNotLocal { url, .. } => assert!(!url.contains("secret")),
1600 other => panic!("got {other:?}"),
1601 }
1602 assert!(matches!(
1603 offload_err(&fx, &with_filter("self")),
1604 OffloadError::LopIsMainRepository
1605 ));
1606 assert!(matches!(
1607 offload_err(&fx, &with_filter("gone")),
1608 OffloadError::LopNotARepository { .. }
1609 ));
1610
1611 let sha256 = fx.dir.path().join("sha256.git");
1612 Repository::init(
1613 &sha256,
1614 &InitOptions::new()
1615 .with_bare(true)
1616 .with_object_format(HashAlgo::Sha256),
1617 )
1618 .unwrap();
1619 let mut opts = with_filter("web");
1620 opts.lop_git_dir = Some(sha256);
1621 assert!(matches!(
1622 offload_err(&fx, &opts),
1623 OffloadError::HashAlgoMismatch {
1624 main: HashAlgo::Sha1,
1625 lop: HashAlgo::Sha256
1626 }
1627 ));
1628
1629 std::fs::write(fx.main_dir().join("gc.pid"), "1 some-other-host").unwrap();
1630 assert!(matches!(
1631 offload_err(&fx, &with_filter("lop")),
1632 OffloadError::MaintenanceRunning { .. }
1633 ));
1634 }
1635}