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.get_bool(key).and_then(|v| v.ok()).unwrap_or(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
437 .get_bool("promisor.advertise")
438 .and_then(|v| v.ok())
439 .unwrap_or(false),
440 };
441 if opts.dry_run {
442 return Ok(report);
443 }
444
445 if !missing_in_lop.is_empty() {
446 report.lop_pack = copy_to_lop(repo, &lop, &missing_in_lop, opts)?
447 .map(|(path, objects)| OffloadPack::written(OffloadPackRole::Lop, path, objects))
448 .transpose()?;
449 report.copied_to_lop = missing_in_lop.len();
450 }
451 lop.odb().invalidate_packs();
452 let present = held_in_lop_keep_packs(lop.odb(), &candidates)?;
453 if let Some((oid, _)) = candidates.iter().zip(&present).find(|(_, has)| !**has) {
454 return Err(OffloadError::NotInLopAfterCopy { oid: *oid }.into());
455 }
456 if let Some(pack) = &report.lop_pack {
457 crate::index_pack::sync_installed_pack(&pack.path)?;
459 }
460
461 let mut wanted: Vec<(String, String)> = Vec::new();
462 let promisor_key = format!("remote.{remote}.promisor");
463 if config.get_bool(&promisor_key).and_then(|v| v.ok()) != Some(true) {
464 wanted.push((promisor_key, "true".to_owned()));
465 }
466 let filter_key = format!("remote.{remote}.partialCloneFilter");
467 if opts.filter.is_some() && config.get(&filter_key).is_none() {
468 wanted.push((filter_key, filter.to_config_value()));
469 }
470 let bitmaps_key = "repack.writeBitmaps";
474 if config.get_bool(bitmaps_key).and_then(|v| v.ok()) != Some(false) {
475 wanted.push((bitmaps_key.to_owned(), "false".to_owned()));
476 }
477 let mut added: Vec<(String, String)> = Vec::new();
480 let lop_dir = lop_dir_value(lop.git_dir());
481 if !config
482 .get_all(LOP_GIT_DIR_KEY)
483 .iter()
484 .any(|v| same_dir(Path::new(v), lop.git_dir()))
485 {
486 added.push((LOP_GIT_DIR_KEY.to_owned(), lop_dir));
487 }
488 write_config(repo.git_dir(), &wanted, &added)?;
489 report
490 .config_changes
491 .extend(
492 wanted
493 .into_iter()
494 .chain(added)
495 .map(|(key, value)| OffloadConfigChange {
496 repo: OffloadRepo::Main,
497 key,
498 value,
499 }),
500 );
501 if opts.configure_lop {
502 let lop_changes: Vec<(String, String)> = [
503 ("uploadpack.allowFilter", report.lop_ready.allow_filter),
504 (
505 "uploadpack.allowAnySHA1InWant",
506 report.lop_ready.allow_any_sha1_in_want,
507 ),
508 ]
509 .into_iter()
510 .filter(|(_, already)| !already)
511 .map(|(key, _)| (key.to_owned(), "true".to_owned()))
512 .collect();
513 write_config(lop.git_dir(), &lop_changes, &[])?;
514 report
515 .config_changes
516 .extend(
517 lop_changes
518 .into_iter()
519 .map(|(key, value)| OffloadConfigChange {
520 repo: OffloadRepo::Lop,
521 key,
522 value,
523 }),
524 );
525 report.lop_ready = LopReadiness::from_config(&lop_config(&lop)?);
526 }
527
528 let omit: OidHashSet = candidates.iter().copied().collect();
529 let repack = RepackOptions {
530 threads: opts.threads,
531 environment: repo.environment().clone(),
532 ..RepackOptions::default()
533 };
534 let rewrite = crate::maintenance::rewrite_local_objects_without(
538 repo,
539 &omit,
540 &repack,
541 crate::maintenance::PromisorRewriteWhen::Always,
542 )?;
543 report.main_pack = rewrite
544 .pack_path
545 .map(|path| OffloadPack::written(OffloadPackRole::MainPromisor, path, rewrite.objects))
546 .transpose()?;
547 report.dropped_from_main = odb.missing_stored_oids(candidates.iter().copied())?.len();
548 report.main_bytes_after = local_object_bytes(odb)?;
549 Ok(report)
550}
551
552fn open_lop(repo: &Repository, opts: &OffloadOptions, url: &str) -> Result<Repository> {
555 let path = match &opts.lop_git_dir {
556 Some(path) => path.clone(),
557 None if crate::transport_path::is_local_path_remote_url(url) => {
558 crate::transport_path::resolve_local_remote_git_dir(
559 url,
560 repo.git_dir(),
561 repo.work_tree(),
562 )
563 }
564 None => {
565 return Err(OffloadError::LopNotLocal {
566 remote: opts.remote.clone(),
567 url: crate::transport_path::redact_url_for_display(url),
568 }
569 .into())
570 }
571 };
572 let lop = Repository::open(
573 &path,
574 &RepositoryOptions::with_environment(repo.environment().clone()),
575 )
576 .map_err(|_| OffloadError::LopNotARepository { path: path.clone() })?;
577 if same_object_store(repo, &lop) {
578 return Err(OffloadError::LopIsMainRepository.into());
579 }
580 let (main_algo, lop_algo) = (repo.odb().hash_algo(), lop.odb().hash_algo());
581 if main_algo != lop_algo {
582 return Err(OffloadError::HashAlgoMismatch {
583 main: main_algo,
584 lop: lop_algo,
585 }
586 .into());
587 }
588 lop.odb().require_files_primary("offload")?;
589 Ok(lop)
590}
591
592fn lop_config(lop: &Repository) -> Result<ConfigSet> {
594 ConfigSet::load(lop.environment(), Some(lop.git_dir()), true)
595}
596
597struct Selection {
599 candidates: OidHashMap<u64>,
600 skipped_checked_out: usize,
601 skipped_kept_pack: usize,
602}
603
604fn select_candidates(repo: &Repository, filter: ObjectFilterSpec) -> Result<Selection> {
611 let odb = repo.odb();
612 let pack_dir = odb.objects_dir().join("pack");
613 let (kept, rewritten): (Vec<_>, Vec<_>) = odb
614 .local_pack_indexes()?
615 .into_iter()
616 .partition(|idx| pack_dir.join(keep_name(&idx.pack_path)).is_file());
617 let in_any = |indexes: &[std::sync::Arc<PackIndex>], oid: &ObjectId| {
618 indexes.iter().any(|idx| idx.find_position(oid).is_some())
619 };
620 let will_be_rewritten =
621 |oid: &ObjectId| in_any(&rewritten, oid) || odb.local_loose_file_size(oid).is_some();
622 let checked_out = crate::promisor::blobs_needed_by_checkouts(repo)?;
623
624 let mut selection = Selection {
625 candidates: crate::objects::oid_hash_map(),
626 skipped_checked_out: 0,
627 skipped_kept_pack: 0,
628 };
629 let mut unanchored: OidHashMap<u64> = crate::objects::oid_hash_map();
631 let mut blobs_seen = oid_hash_set();
632 let mut walked = oid_hash_set();
633 let mut queue: Vec<ObjectId> = crate::maintenance::collect_maintenance_object_roots(repo)?;
634 let oid_len = odb.hash_algo().len();
635 while let Some(oid) = queue.pop() {
636 if !walked.insert(oid) {
637 continue;
638 }
639 let Some(obj) = odb.try_read_once(&oid)? else {
640 continue;
641 };
642 match obj.kind {
643 ObjectKind::Commit => {
644 let commit = CommitRef::parse(&obj.data)?;
645 queue.push(commit.tree());
646 queue.extend(commit.parents().iter().copied());
647 }
648 ObjectKind::Tag => {
649 if let Some(target) = crate::objects::tag_object_line_oid(&obj.data) {
650 queue.push(target);
651 }
652 }
653 ObjectKind::Tree => {
654 let anchors = will_be_rewritten(&oid);
655 for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
656 let entry = entry?;
657 match entry.mode & 0o170000 {
658 0o100000 | 0o120000 => {}
659 0o040000 => {
660 queue.push(entry.oid);
661 continue;
662 }
663 _ => continue,
665 }
666 if anchors {
667 if let Some(size) = unanchored.remove(&entry.oid) {
668 selection.candidates.insert(entry.oid, size);
669 }
670 }
671 if !blobs_seen.insert(entry.oid) {
672 continue;
673 }
674 let Some(size) = selected_blob_size(odb, filter, &entry.oid)? else {
675 continue;
676 };
677 if checked_out.contains(&entry.oid) {
678 selection.skipped_checked_out += 1;
679 } else if in_any(&kept, &entry.oid) {
680 selection.skipped_kept_pack += 1;
681 } else if anchors {
682 selection.candidates.insert(entry.oid, size);
683 } else {
684 unanchored.insert(entry.oid, size);
685 }
686 }
687 }
688 ObjectKind::Blob => {}
689 }
690 }
691 selection.skipped_kept_pack += unanchored.len();
692 Ok(selection)
693}
694
695fn kept_object_the_promisor_pack_names(
702 odb: &Odb,
703 omit: &OidHashMap<u64>,
704) -> Result<Option<OffloadError>> {
705 odb.reprepare_local_packs();
706 let (kept, rewritten): (Vec<_>, Vec<_>) = odb
707 .local_pack_indexes()?
708 .into_iter()
709 .partition(|idx| idx.pack_path.with_extension("keep").is_file());
710 let (kept_promisor, kept_ordinary): (Vec<_>, Vec<_>) =
711 kept.into_iter().partition(|idx| idx.is_promisor);
712 if kept_ordinary.is_empty() {
713 return Ok(None);
714 }
715 let loose: OidHashSet = odb
716 .enumerate_local_loose_objects()?
717 .into_iter()
718 .map(|(oid, _)| oid)
719 .collect();
720 let in_promisor_pack_after = |oid: &ObjectId| {
721 loose.contains(oid)
722 || [&rewritten, &kept_promisor]
723 .into_iter()
724 .flatten()
725 .any(|idx| idx.find_position(oid).is_some())
726 };
727 let mut only_kept: OidHashMap<usize> = crate::objects::oid_hash_map();
728 for (pack_no, idx) in kept_ordinary.iter().enumerate() {
729 for entry in idx.iter() {
730 let oid = ObjectId::from_bytes(entry.oid())?;
731 if !in_promisor_pack_after(&oid) {
732 only_kept.entry(oid).or_insert(pack_no);
733 }
734 }
735 }
736 if only_kept.is_empty() {
737 return Ok(None);
738 }
739 let mut referrers: Vec<ObjectId> = loose.iter().copied().collect();
740 for idx in &rewritten {
741 for entry in idx.iter() {
742 referrers.push(ObjectId::from_bytes(entry.oid())?);
743 }
744 }
745 let oid_len = odb.hash_algo().len();
746 let mut checked = oid_hash_set();
747 for referrer in referrers {
748 if omit.contains_key(&referrer) || !checked.insert(referrer) {
749 continue;
750 }
751 let kind = match odb.read_info_without_fetch(&referrer) {
752 Ok(info) => info.kind,
753 Err(Error::ObjectNotFound(_)) => continue,
754 Err(err) => return Err(err),
755 };
756 if kind == ObjectKind::Blob {
757 continue;
758 }
759 let obj = odb.read(&referrer)?;
760 let mut named: Vec<ObjectId> = Vec::new();
761 match kind {
762 ObjectKind::Commit => {
763 let commit = CommitRef::parse(&obj.data)?;
764 named.push(commit.tree());
765 named.extend(commit.parents().iter().copied());
766 }
767 ObjectKind::Tree => {
768 for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
769 let entry = entry?;
770 if entry.mode & 0o170000 != 0o160000 {
771 named.push(entry.oid);
772 }
773 }
774 }
775 ObjectKind::Tag => named.extend(crate::objects::tag_object_line_oid(&obj.data)),
776 ObjectKind::Blob => {}
777 }
778 if let Some((oid, &pack_no)) = named.iter().find_map(|oid| only_kept.get_key_value(oid)) {
779 return Ok(Some(OffloadError::KeptPackObjectPromised {
780 pack: kept_ordinary[pack_no].pack_path.clone(),
781 oid: *oid,
782 referrer,
783 }));
784 }
785 }
786 Ok(None)
787}
788
789fn selected_blob_size(odb: &Odb, filter: ObjectFilterSpec, oid: &ObjectId) -> Result<Option<u64>> {
791 if !odb.try_exists_stored(oid)? {
792 return Ok(None);
793 }
794 let info = match odb.read_info_without_fetch(oid) {
795 Ok(info) => info,
796 Err(Error::ObjectNotFound(_)) => return Ok(None),
797 Err(err) => return Err(err),
798 };
799 if info.kind != ObjectKind::Blob {
800 return Ok(None);
801 }
802 let selected = match filter {
803 ObjectFilterSpec::BlobNone => true,
804 ObjectFilterSpec::BlobLimit(limit) => info.size >= limit,
805 };
806 Ok(selected.then_some(info.size))
807}
808
809fn keep_name(pack_path: &Path) -> PathBuf {
810 PathBuf::from(pack_path.file_name().unwrap_or_default()).with_extension("keep")
811}
812
813fn copy_to_lop(
816 repo: &Repository,
817 lop: &Repository,
818 blobs: &[ObjectId],
819 opts: &OffloadOptions,
820) -> Result<Option<(PathBuf, usize)>> {
821 let cfg = repo.config().ok();
822 let pack_opts = PackBuildOptions {
823 delta: false,
824 window: 0,
825 max_depth: 0,
826 reuse_deltas: false,
827 reuse_objects: true,
828 use_bitmaps: false,
829 thin: false,
830 ..PackBuildOptions::for_local_copy(cfg.as_deref())
831 };
832 let lop_pack_dir = lop.odb().objects_dir().join("pack");
833 let built = crate::maintenance::write_staged_repack_pack(
834 repo.odb(),
835 &lop_pack_dir,
836 blobs,
837 &std::collections::HashMap::new(),
838 &pack_opts,
839 opts.threads,
840 cfg.as_deref(),
841 )?;
842 let Some(staging) = built.staging_path.as_ref() else {
843 return Ok(None);
844 };
845 let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
846 let ingested = install_pack_path(
847 staging,
848 lop.odb(),
849 &IngestPackOptions {
850 threads: opts.threads,
851 environment: lop.environment().clone(),
852 skip_post_index_verify: trusted_rows.is_some(),
853 trusted_index_rows: trusted_rows,
854 keep_message: Some(LOP_KEEP_MESSAGE.to_owned()),
855 ..IngestPackOptions::default()
856 },
857 )?;
858 let objects = ingested.object_ids.len();
859 Ok(ingested.pack_path.map(|path| (path, objects)))
860}
861
862fn held_in_lop_keep_packs(lop: &Odb, oids: &[ObjectId]) -> Result<Vec<bool>> {
865 let kept = lop_keep_packs(lop)?;
866 Ok(oids
867 .iter()
868 .map(|oid| kept.iter().any(|idx| idx.find_position(oid).is_some()))
869 .collect())
870}
871
872pub(crate) fn lop_keep_packs(lop: &Odb) -> Result<Vec<std::sync::Arc<PackIndex>>> {
886 Ok(lop
887 .local_pack_indexes()?
888 .into_iter()
889 .filter(|idx| has_lop_keep_marker(&idx.pack_path))
890 .collect())
891}
892
893fn has_lop_keep_marker(pack_path: &Path) -> bool {
895 std::fs::read(pack_path.with_extension("keep"))
896 .is_ok_and(|contents| contents.trim_ascii_end() == LOP_KEEP_MESSAGE.as_bytes())
897}
898
899fn write_config(
902 git_dir: &Path,
903 changes: &[(String, String)],
904 additions: &[(String, String)],
905) -> Result<()> {
906 if changes.is_empty() && additions.is_empty() {
907 return Ok(());
908 }
909 let path = git_dir.join("config");
910 let content = match std::fs::read_to_string(&path) {
911 Ok(content) => content,
912 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
913 Err(err) => return Err(Error::Io(err)),
914 };
915 let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
916 for (key, value) in changes {
917 config.set(key, value)?;
918 }
919 for (key, value) in additions {
920 config.add_value(key, value)?;
921 }
922 config.write()
923}
924
925fn lop_dir_value(git_dir: &Path) -> String {
928 use crate::git_path::CanonicalizeExt as _;
929 git_dir
930 .canonicalize_simplified()
931 .unwrap_or_else(|_| git_dir.to_path_buf())
932 .to_string_lossy()
933 .into_owned()
934}
935
936pub(crate) fn same_object_store(a: &Repository, b: &Repository) -> bool {
939 same_dir(a.odb().objects_dir(), b.odb().objects_dir())
940}
941
942pub(crate) fn same_dir(a: &Path, b: &Path) -> bool {
944 use crate::git_path::CanonicalizeExt as _;
945 match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
946 (Ok(a), Ok(b)) => a == b,
947 _ => a == b,
948 }
949}
950
951fn local_object_bytes(odb: &Odb) -> Result<u64> {
953 odb.invalidate_packs();
954 let mut total = 0u64;
955 for idx in odb.local_pack_indexes()? {
956 for path in [&idx.pack_path, &idx.idx_path] {
957 total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
958 }
959 }
960 for (_, path) in odb.enumerate_local_loose_objects()? {
961 total += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
962 }
963 Ok(total)
964}
965
966#[cfg(test)]
967#[allow(clippy::unwrap_used, clippy::expect_used)]
968mod tests {
969 use super::*;
970 use crate::repo::InitOptions;
971
972 const NOW: i64 = 1_700_000_000;
973
974 struct Fixture {
977 dir: tempfile::TempDir,
978 blobs: Vec<ObjectId>,
979 }
980
981 fn blob_data(seed: usize, size: usize) -> Vec<u8> {
983 let mut state = (seed as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
984 (0..size)
985 .map(|_| {
986 state ^= state << 13;
987 state ^= state >> 7;
988 state ^= state << 17;
989 state.to_le_bytes()[0]
990 })
991 .collect()
992 }
993
994 impl Fixture {
995 fn new(sizes: &[usize]) -> Self {
996 let dir = tempfile::tempdir().unwrap();
997 let init = InitOptions::new()
998 .with_bare(true)
999 .with_initial_branch("main");
1000 let main = Repository::init(&dir.path().join("main.git"), &init).unwrap();
1001 Repository::init(&dir.path().join("lop.git"), &init).unwrap();
1002 let odb = main.odb();
1003 let blobs: Vec<ObjectId> = sizes
1004 .iter()
1005 .enumerate()
1006 .map(|(i, &size)| odb.write(ObjectKind::Blob, &blob_data(i, size)).unwrap())
1007 .collect();
1008 let mut entries: Vec<crate::objects::TreeEntry> = blobs
1009 .iter()
1010 .enumerate()
1011 .map(|(i, oid)| crate::objects::TreeEntry {
1012 mode: 0o100644,
1013 name: format!("f{i}").into_bytes(),
1014 oid: *oid,
1015 })
1016 .collect();
1017 entries.sort_by(|a, b| a.name.cmp(&b.name));
1018 let tree = odb
1019 .write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
1020 .unwrap();
1021 let commit =
1022 format!("tree {tree}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nseed\n");
1023 let commit = odb.write(ObjectKind::Commit, commit.as_bytes()).unwrap();
1024 std::fs::write(
1025 dir.path().join("main.git/refs/heads/main"),
1026 format!("{commit}\n"),
1027 )
1028 .unwrap();
1029 let fx = Self { dir, blobs };
1030 fx.append_main_config("[remote \"lop\"]\n\turl = ../lop.git\n");
1031 fx
1032 }
1033
1034 fn main_dir(&self) -> PathBuf {
1035 self.dir.path().join("main.git")
1036 }
1037
1038 fn lop_dir(&self) -> PathBuf {
1039 self.dir.path().join("lop.git")
1040 }
1041
1042 fn main(&self) -> Repository {
1043 Repository::open(&self.main_dir(), &RepositoryOptions::empty()).unwrap()
1044 }
1045
1046 fn lop(&self) -> Repository {
1047 Repository::open(&self.lop_dir(), &RepositoryOptions::empty()).unwrap()
1048 }
1049
1050 fn append_main_config(&self, text: &str) {
1051 let path = self.main_dir().join("config");
1052 let mut config = std::fs::read_to_string(&path).unwrap();
1053 config.push_str(text);
1054 std::fs::write(path, config).unwrap();
1055 }
1056
1057 fn offload(&self, limit: u64) -> OffloadReport {
1058 let mut opts = OffloadOptions::new("lop", NOW);
1059 opts.filter = Some(ObjectFilterSpec::BlobLimit(limit));
1060 offload_blobs(&self.main(), &opts).expect("offload")
1061 }
1062
1063 fn files(&self, dir: &Path, ext: &str) -> Vec<PathBuf> {
1064 std::fs::read_dir(dir.join("objects/pack"))
1065 .map(|entries| {
1066 entries
1067 .flatten()
1068 .map(|e| e.path())
1069 .filter(|p| p.extension().is_some_and(|e| e == ext))
1070 .collect()
1071 })
1072 .unwrap_or_default()
1073 }
1074 }
1075
1076 #[test]
1077 fn blobs_at_the_limit_move_and_smaller_ones_stay() {
1078 let fx = Fixture::new(&[10_000, 9_999, 30_000]);
1079 let report = fx.offload(10_000);
1080 assert_eq!(report.candidates, 2);
1081 assert_eq!(report.candidate_bytes, 40_000);
1082 assert_eq!(report.copied_to_lop, 2);
1083 assert_eq!(report.dropped_from_main, 2);
1084 assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(10_000));
1085
1086 let main = fx.main();
1087 let lop = fx.lop();
1088 for (i, moved) in [(0, true), (1, false), (2, true)] {
1089 let blob = fx.blobs[i];
1090 assert_eq!(!main.odb().exists_stored(&blob), moved, "main f{i}");
1091 assert_eq!(lop.odb().exists_stored(&blob), moved, "lop f{i}");
1092 }
1093 let promisor = fx.files(&fx.main_dir(), "promisor");
1096 assert_eq!(promisor.len(), 1);
1097 assert_eq!(
1098 report.main_pack.as_ref().and_then(|p| p.path.file_name()),
1099 promisor[0].with_extension("pack").file_name()
1100 );
1101 let indexes = main.odb().local_pack_indexes().unwrap();
1102 let promisor_idx = indexes
1103 .iter()
1104 .find(|i| i.is_promisor)
1105 .expect("promisor pack");
1106 assert_eq!(indexes.len(), 1);
1107 assert_eq!(promisor_idx.len(), 3, "the commit, its tree and f1");
1108 assert!(promisor_idx.find_position(&fx.blobs[1]).is_some());
1109 assert!(main
1110 .odb()
1111 .enumerate_local_loose_objects()
1112 .unwrap()
1113 .is_empty());
1114 assert!(report.main_bytes_after < report.main_bytes_before);
1115
1116 let roles: Vec<_> = report.packs().map(|p| (p.role, p.objects)).collect();
1118 assert_eq!(
1119 roles,
1120 [
1121 (OffloadPackRole::Lop, 2),
1122 (OffloadPackRole::MainPromisor, 3),
1123 ]
1124 );
1125 for pack in report.packs() {
1126 assert_eq!(pack.bytes, std::fs::metadata(&pack.path).unwrap().len());
1127 }
1128 assert!(report
1129 .lop_pack
1130 .as_ref()
1131 .unwrap()
1132 .path
1133 .starts_with(fx.lop().git_dir()));
1134 }
1135
1136 #[test]
1137 fn lop_pack_is_kept_and_complete() {
1138 let fx = Fixture::new(&[4096]);
1139 let report = fx.offload(1024);
1140 let lop_pack = report.lop_pack.expect("lop pack").path;
1141 assert_eq!(
1142 std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap(),
1143 format!("{LOP_KEEP_MESSAGE}\n")
1144 );
1145 let blob = fx.lop().odb().read(&fx.blobs[0]).unwrap();
1146 assert_eq!(blob.data, blob_data(0, 4096));
1147 }
1148
1149 #[test]
1150 fn config_is_set_in_main_and_optionally_in_the_lop() {
1151 let fx = Fixture::new(&[2048]);
1152 let mut opts = OffloadOptions::new("lop", NOW);
1153 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1154 opts.configure_lop = true;
1155 let report = offload_blobs(&fx.main(), &opts).unwrap();
1156 let lop_dir = lop_dir_value(fx.lop().git_dir());
1157 assert!(Path::new(&lop_dir).is_absolute());
1158 let keys: Vec<(OffloadRepo, &str, &str)> = report
1159 .config_changes
1160 .iter()
1161 .map(|c| (c.repo, c.key.as_str(), c.value.as_str()))
1162 .collect();
1163 assert_eq!(
1164 keys,
1165 [
1166 (OffloadRepo::Main, "remote.lop.promisor", "true"),
1167 (
1168 OffloadRepo::Main,
1169 "remote.lop.partialCloneFilter",
1170 "blob:limit=1024"
1171 ),
1172 (OffloadRepo::Main, "repack.writeBitmaps", "false"),
1173 (OffloadRepo::Main, LOP_GIT_DIR_KEY, lop_dir.as_str()),
1174 (OffloadRepo::Lop, "uploadpack.allowFilter", "true"),
1175 (OffloadRepo::Lop, "uploadpack.allowAnySHA1InWant", "true"),
1176 ]
1177 );
1178 assert!(report.lop_ready.is_ready());
1179 assert!(!report.advertised);
1180 let config = fx.main().config().unwrap();
1181 assert_eq!(config.get("remote.lop.promisor").as_deref(), Some("true"));
1182 assert!(config.get("extensions.partialclone").is_none());
1183 assert_eq!(
1184 crate::partial_clone::stored_remote_filter(&config, "lop"),
1185 Some(ObjectFilterSpec::BlobLimit(1024))
1186 );
1187 assert_eq!(config.get_all(LOP_GIT_DIR_KEY), [lop_dir.clone()]);
1188
1189 let again = offload_blobs(&fx.main(), &opts).unwrap();
1191 assert!(
1192 again.config_changes.is_empty(),
1193 "{:?}",
1194 again.config_changes
1195 );
1196 assert_eq!(
1197 fx.main().config().unwrap().get_all(LOP_GIT_DIR_KEY),
1198 [lop_dir]
1199 );
1200 }
1201
1202 #[test]
1203 fn stored_filter_is_used_and_lop_is_left_alone_by_default() {
1204 let fx = Fixture::new(&[2048, 10]);
1205 fx.append_main_config("\tpartialCloneFilter = blob:limit=1k\n");
1206 let report = offload_blobs(&fx.main(), &OffloadOptions::new("lop", NOW)).unwrap();
1207 assert_eq!(report.candidates, 1);
1208 assert_eq!(report.filter, ObjectFilterSpec::BlobLimit(1024));
1209 let keys: Vec<&str> = report
1210 .config_changes
1211 .iter()
1212 .map(|c| c.key.as_str())
1213 .collect();
1214 assert_eq!(
1215 keys,
1216 [
1217 "remote.lop.promisor",
1218 "repack.writeBitmaps",
1219 LOP_GIT_DIR_KEY
1220 ]
1221 );
1222 assert_eq!(report.lop_ready, LopReadiness::default());
1223 }
1224
1225 fn tree_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
1227 let mut out = Vec::new();
1228 let mut stack = vec![dir.to_path_buf()];
1229 while let Some(path) = stack.pop() {
1230 if path.is_dir() {
1231 stack.extend(
1232 std::fs::read_dir(&path)
1233 .unwrap()
1234 .flatten()
1235 .map(|e| e.path()),
1236 );
1237 } else {
1238 out.push((path.clone(), std::fs::read(&path).unwrap()));
1239 }
1240 }
1241 out.sort();
1242 out
1243 }
1244
1245 #[test]
1246 fn dry_run_writes_nothing() {
1247 let fx = Fixture::new(&[2048]);
1248 let before = tree_snapshot(&fx.main_dir());
1249 let mut opts = OffloadOptions::new("lop", NOW);
1250 opts.filter = Some(ObjectFilterSpec::BlobNone);
1251 opts.dry_run = true;
1252 opts.configure_lop = true;
1253 let report = offload_blobs(&fx.main(), &opts).unwrap();
1254 assert!(report.dry_run);
1255 assert_eq!(report.candidates, 1);
1256 assert_eq!(report.copied_to_lop, 0);
1257 assert!(report.config_changes.is_empty());
1258 assert_eq!(tree_snapshot(&fx.main_dir()), before);
1259 assert!(fx.files(&fx.lop_dir(), "pack").is_empty());
1260 assert!(!fx.main_dir().join("gc.pid").exists());
1261 }
1262
1263 #[test]
1264 fn running_again_is_a_no_op_until_new_blobs_arrive() {
1265 let fx = Fixture::new(&[2048, 10]);
1266 fx.offload(1024);
1267 let again = fx.offload(1024);
1268 assert_eq!(again.candidates, 0);
1269 assert_eq!(again.copied_to_lop, 0);
1270 assert!(again.main_pack.is_none(), "nothing to rewrite");
1271 assert_eq!(fx.files(&fx.lop_dir(), "pack").len(), 1);
1272 assert_eq!(fx.files(&fx.main_dir(), "pack").len(), 1);
1273 }
1274
1275 fn pack_into_lop(fx: &Fixture, message: &str) {
1277 let pack = crate::pack_objects::build_pack_from_send_list(
1278 fx.main().odb(),
1279 &[fx.blobs[0]],
1280 &std::collections::HashSet::new(),
1281 &PackBuildOptions::default(),
1282 &std::collections::HashMap::new(),
1283 )
1284 .unwrap();
1285 crate::index_pack::install_pack_bytes(
1286 pack,
1287 fx.lop().odb(),
1288 &IngestPackOptions {
1289 keep_message: Some(message.to_owned()),
1290 ..IngestPackOptions::default()
1291 },
1292 )
1293 .unwrap();
1294 }
1295
1296 #[test]
1297 fn blobs_the_lop_keeps_are_not_copied_again() {
1298 let fx = Fixture::new(&[2048]);
1299 pack_into_lop(&fx, LOP_KEEP_MESSAGE);
1300 let report = fx.offload(1024);
1301 assert_eq!(report.already_in_lop, 1);
1302 assert_eq!(report.copied_to_lop, 0);
1303 assert!(report.lop_pack.is_none());
1304 assert_eq!(report.dropped_from_main, 1);
1305 }
1306
1307 #[test]
1308 fn a_copy_another_writer_keeps_for_a_while_is_not_trusted() {
1309 for message in ["receive-pack 4242 on build-host", "pinned"] {
1312 let fx = Fixture::new(&[2048]);
1313 pack_into_lop(&fx, message);
1314 let report = fx.offload(1024);
1315 assert_eq!(report.already_in_lop, 0, "{message}");
1316 assert_eq!(report.copied_to_lop, 1, "{message}");
1317 let lop_pack = report.lop_pack.expect("copied").path;
1318 let keep = std::fs::read_to_string(lop_pack.with_extension("keep")).unwrap();
1319 assert_eq!(keep.trim_end(), LOP_KEEP_MESSAGE);
1320 }
1321 }
1322
1323 #[cfg(unix)]
1324 #[test]
1325 fn a_lop_sharing_main_objects_is_refused() {
1326 let fx = Fixture::new(&[2048]);
1327 let lop_objects = fx.lop_dir().join("objects");
1328 std::fs::remove_dir_all(&lop_objects).unwrap();
1329 std::os::unix::fs::symlink(fx.main_dir().join("objects"), &lop_objects).unwrap();
1330 let mut opts = OffloadOptions::new("lop", NOW);
1331 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1332 assert!(matches!(
1333 offload_err(&fx, &opts),
1334 OffloadError::LopIsMainRepository
1335 ));
1336 assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1337 }
1338
1339 fn prune_lop_loose(fx: &Fixture) {
1342 for (_, path) in fx.lop().odb().enumerate_local_loose_objects().unwrap() {
1343 std::fs::remove_file(path).unwrap();
1344 }
1345 }
1346
1347 #[test]
1348 fn a_loose_copy_in_the_lop_is_not_trusted() {
1349 let fx = Fixture::new(&[2048]);
1350 fx.lop()
1351 .odb()
1352 .write(ObjectKind::Blob, &blob_data(0, 2048))
1353 .unwrap();
1354 let report = fx.offload(1024);
1355 assert_eq!(report.already_in_lop, 0);
1356 assert_eq!(report.copied_to_lop, 1);
1357 assert!(report.lop_pack.is_some());
1358 assert_eq!(report.dropped_from_main, 1);
1359 prune_lop_loose(&fx);
1360 let lop = fx.lop();
1361 assert_eq!(
1362 lop.odb().read(&fx.blobs[0]).unwrap().data,
1363 blob_data(0, 2048),
1364 "the kept pack survives the LOP's prune"
1365 );
1366 }
1367
1368 #[test]
1369 fn a_copy_the_lop_borrows_from_main_is_not_trusted() {
1370 let fx = Fixture::new(&[2048]);
1371 let alternates = fx.lop_dir().join("objects/info/alternates");
1372 std::fs::create_dir_all(alternates.parent().unwrap()).unwrap();
1373 std::fs::write(
1374 &alternates,
1375 format!("{}\n", fx.main_dir().join("objects").display()),
1376 )
1377 .unwrap();
1378 let report = fx.offload(1024);
1379 assert_eq!(report.already_in_lop, 0);
1380 assert_eq!(report.copied_to_lop, 1);
1381 std::fs::remove_file(&alternates).unwrap();
1382 assert_eq!(
1383 fx.lop().odb().read(&fx.blobs[0]).unwrap().data,
1384 blob_data(0, 2048),
1385 "the LOP has its own copy"
1386 );
1387 }
1388
1389 #[test]
1390 fn running_again_folds_pushed_objects_into_the_promisor_pack() {
1391 let fx = Fixture::new(&[2048, 10]);
1392 fx.offload(1024);
1393 let main = fx.main();
1395 let mut body = b"100644 big\0".to_vec();
1396 body.extend_from_slice(fx.blobs[0].as_bytes());
1397 let tree = main
1398 .odb()
1399 .write_loose_materialize(ObjectKind::Tree, &body)
1400 .unwrap();
1401 let again = fx.offload(1024);
1402 assert_eq!(again.candidates, 0);
1403 let pack = again.main_pack.expect("folded").path;
1404 let promisor: Vec<_> = fx
1405 .files(&fx.main_dir(), "promisor")
1406 .iter()
1407 .map(|p| p.file_name().map(|n| n.to_os_string()))
1408 .collect();
1409 assert_eq!(
1410 promisor,
1411 [pack
1412 .with_extension("promisor")
1413 .file_name()
1414 .map(|n| n.to_os_string())]
1415 );
1416 let main = fx.main();
1417 assert!(main
1418 .odb()
1419 .enumerate_local_loose_objects()
1420 .unwrap()
1421 .is_empty());
1422 assert!(main.odb().exists_stored(&tree));
1423 assert!(!main.odb().exists_stored(&fx.blobs[0]));
1424 }
1425
1426 #[test]
1427 fn blobs_in_kept_packs_stay() {
1428 let fx = Fixture::new(&[2048]);
1429 let main = fx.main();
1430 let pack = crate::pack_objects::build_pack_from_send_list(
1431 main.odb(),
1432 &[fx.blobs[0]],
1433 &std::collections::HashSet::new(),
1434 &PackBuildOptions::default(),
1435 &std::collections::HashMap::new(),
1436 )
1437 .unwrap();
1438 crate::index_pack::install_pack_bytes(
1439 pack,
1440 main.odb(),
1441 &IngestPackOptions {
1442 keep_message: Some("pinned".into()),
1443 ..IngestPackOptions::default()
1444 },
1445 )
1446 .unwrap();
1447 let report = fx.offload(1024);
1448 assert_eq!(report.candidates, 0);
1449 assert_eq!(report.skipped_kept_pack, 1);
1450 assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1451 }
1452
1453 fn keep_in_main(fx: &Fixture, oids: &[ObjectId]) -> PathBuf {
1456 let main = fx.main();
1457 let pack = crate::pack_objects::build_pack_from_send_list(
1458 main.odb(),
1459 oids,
1460 &std::collections::HashSet::new(),
1461 &PackBuildOptions::default(),
1462 &std::collections::HashMap::new(),
1463 )
1464 .unwrap();
1465 let installed = crate::index_pack::install_pack_bytes(
1466 pack,
1467 main.odb(),
1468 &IngestPackOptions {
1469 keep_message: Some("pinned".into()),
1470 ..IngestPackOptions::default()
1471 },
1472 )
1473 .unwrap();
1474 crate::prune_packed::prune_packed_objects(main.odb().objects_dir(), Default::default())
1475 .unwrap();
1476 installed.pack_path.unwrap()
1477 }
1478
1479 #[test]
1480 fn a_kept_object_the_promisor_pack_would_name_stops_offload() {
1481 let fx = Fixture::new(&[2048, 10]);
1484 let kept = keep_in_main(&fx, &[fx.blobs[1]]);
1485 let mut opts = OffloadOptions::new("lop", NOW);
1486 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1487 for dry_run in [true, false] {
1488 opts.dry_run = dry_run;
1489 match offload_err(&fx, &opts) {
1490 OffloadError::KeptPackObjectPromised { pack, oid, .. } => {
1491 assert_eq!(pack, kept);
1492 assert_eq!(oid, fx.blobs[1]);
1493 }
1494 other => panic!("got {other:?}"),
1495 }
1496 }
1497 assert!(fx.files(&fx.lop_dir(), "pack").is_empty(), "nothing copied");
1498 assert!(fx.files(&fx.main_dir(), "promisor").is_empty());
1499 assert!(fx.main().odb().exists_stored(&fx.blobs[0]));
1500
1501 std::fs::remove_file(kept.with_extension("keep")).unwrap();
1503 let report = fx.offload(1024);
1504 assert_eq!(report.dropped_from_main, 1);
1505 let main = fx.main();
1506 let indexes = main.odb().local_pack_indexes().unwrap();
1507 assert_eq!(indexes.len(), 1);
1508 assert!(indexes[0].is_promisor);
1509 assert!(indexes[0].find_position(&fx.blobs[1]).is_some());
1510 }
1511
1512 #[test]
1513 fn a_kept_pack_nothing_names_does_not_stop_offload() {
1514 let fx = Fixture::new(&[2048]);
1515 let stray = fx
1516 .main()
1517 .odb()
1518 .write(ObjectKind::Blob, b"named by nothing")
1519 .unwrap();
1520 let kept = keep_in_main(&fx, &[stray]);
1521 let report = fx.offload(1024);
1522 assert_eq!(report.dropped_from_main, 1);
1523 assert!(kept.is_file() && kept.with_extension("keep").is_file());
1524 assert!(fx.main().odb().exists_stored(&stray));
1525 }
1526
1527 #[test]
1528 fn blobs_a_checkout_needs_stay() {
1529 let dir = tempfile::tempdir().unwrap();
1530 let work = dir.path().join("work");
1531 let repo =
1532 Repository::init(&work, &InitOptions::new().with_initial_branch("main")).unwrap();
1533 Repository::init(
1534 &dir.path().join("lop.git"),
1535 &InitOptions::new().with_bare(true),
1536 )
1537 .unwrap();
1538 std::fs::write(work.join("big"), vec![b'x'; 4096]).unwrap();
1539 crate::porcelain::add::stage_for_commit(
1540 &repo,
1541 &mut crate::progress::NullProgress,
1542 &crate::progress::NeverCancel,
1543 )
1544 .unwrap();
1545 let ident = "T <t@example.com> 1 +0000".to_string();
1546 let mut req = crate::porcelain::commit::CommitRequest::new("big");
1547 let sig: crate::ident::Signature = ident.parse().unwrap();
1548 req.author = Some(sig.clone());
1549 req.committer = Some(sig);
1550 crate::porcelain::commit::create_commit(
1551 &repo,
1552 &req,
1553 &mut crate::progress::NullProgress,
1554 &crate::progress::NeverCancel,
1555 )
1556 .unwrap();
1557 let config = repo.git_dir().join("config");
1558 let mut text = std::fs::read_to_string(&config).unwrap();
1559 text.push_str(&format!(
1560 "[remote \"lop\"]\n\turl = {}\n",
1561 dir.path().join("lop.git").display()
1562 ));
1563 std::fs::write(&config, text).unwrap();
1564 let repo = Repository::discover(Some(&work), &RepositoryOptions::empty()).unwrap();
1565 let mut opts = OffloadOptions::new("lop", NOW);
1566 opts.filter = Some(ObjectFilterSpec::BlobLimit(1024));
1567 let report = offload_blobs(&repo, &opts).unwrap();
1568 assert_eq!(report.candidates, 0);
1569 assert_eq!(report.skipped_checked_out, 1);
1570 }
1571
1572 fn offload_err(fx: &Fixture, opts: &OffloadOptions) -> OffloadError {
1573 match offload_blobs(&fx.main(), opts) {
1574 Err(Error::Offload(err)) => err,
1575 other => panic!("expected an offload error, got {other:?}"),
1576 }
1577 }
1578
1579 #[test]
1580 fn refusals_are_typed() {
1581 let fx = Fixture::new(&[2048]);
1582 let with_filter = |remote: &str| {
1583 let mut opts = OffloadOptions::new(remote, NOW);
1584 opts.filter = Some(ObjectFilterSpec::BlobNone);
1585 opts
1586 };
1587 assert!(matches!(
1588 offload_err(&fx, &with_filter("nope")),
1589 OffloadError::RemoteNotConfigured { remote } if remote == "nope"
1590 ));
1591 assert!(matches!(
1592 offload_err(&fx, &OffloadOptions::new("lop", NOW)),
1593 OffloadError::NoThreshold { .. }
1594 ));
1595
1596 fx.append_main_config(
1597 "[remote \"web\"]\n\turl = https://user:secret@lop.example/r.git\n\
1598 [remote \"self\"]\n\turl = .\n\
1599 [remote \"gone\"]\n\turl = ../nothing-here.git\n",
1600 );
1601 match offload_err(&fx, &with_filter("web")) {
1602 OffloadError::LopNotLocal { url, .. } => assert!(!url.contains("secret")),
1603 other => panic!("got {other:?}"),
1604 }
1605 assert!(matches!(
1606 offload_err(&fx, &with_filter("self")),
1607 OffloadError::LopIsMainRepository
1608 ));
1609 assert!(matches!(
1610 offload_err(&fx, &with_filter("gone")),
1611 OffloadError::LopNotARepository { .. }
1612 ));
1613
1614 let sha256 = fx.dir.path().join("sha256.git");
1615 Repository::init(
1616 &sha256,
1617 &InitOptions::new()
1618 .with_bare(true)
1619 .with_object_format(HashAlgo::Sha256),
1620 )
1621 .unwrap();
1622 let mut opts = with_filter("web");
1623 opts.lop_git_dir = Some(sha256);
1624 assert!(matches!(
1625 offload_err(&fx, &opts),
1626 OffloadError::HashAlgoMismatch {
1627 main: HashAlgo::Sha1,
1628 lop: HashAlgo::Sha256
1629 }
1630 ));
1631
1632 std::fs::write(fx.main_dir().join("gc.pid"), "1 some-other-host").unwrap();
1633 assert!(matches!(
1634 offload_err(&fx, &with_filter("lop")),
1635 OffloadError::MaintenanceRunning { .. }
1636 ));
1637 }
1638}