1use std::cell::OnceCell;
20use std::collections::{HashSet, VecDeque};
21use std::path::Path;
22use std::time::SystemTime;
23
24use crate::error::{Error, Result};
25use crate::objects::{parse_commit, parse_tag, ObjectId, ObjectKind, OidHashSet, TreeRefIter};
26use crate::odb::Odb;
27use crate::refs::store::{Expected, RawRef, RefUpdate, RefUpdateFlags};
28use crate::refs::{self, Ref};
29
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub struct PruneStats {
33 pub pruned: usize,
35 pub kept: usize,
37}
38
39pub fn prune_loose_unreachable(
75 odb: &Odb,
76 reachable_roots: &[ObjectId],
77 keep_newer_than: Option<SystemTime>,
78) -> Result<PruneStats> {
79 prune_loose_unreachable_with_dry_run(odb, reachable_roots, keep_newer_than, false)
80}
81
82pub fn prune_loose_unreachable_with_dry_run(
88 odb: &Odb,
89 reachable_roots: &[ObjectId],
90 keep_newer_than: Option<SystemTime>,
91 dry_run: bool,
92) -> Result<PruneStats> {
93 let shallow = odb
94 .config_git_dir()
95 .map(crate::shallow::load_shallow_boundaries)
96 .unwrap_or_default();
97 prune_loose_unreachable_with(
98 odb,
99 &LazyReachable::new(odb, reachable_roots, &shallow),
100 keep_newer_than,
101 dry_run,
102 )
103}
104
105pub(crate) fn prune_loose_unreachable_with(
108 odb: &Odb,
109 reachable: &LazyReachable<'_>,
110 keep_newer_than: Option<SystemTime>,
111 dry_run: bool,
112) -> Result<PruneStats> {
113 odb.require_files_primary("gc")?;
114 let mut stats = PruneStats::default();
115
116 let candidates: Vec<_> = odb
119 .enumerate_local_loose_objects()?
120 .into_iter()
121 .filter(|(_, path)| {
122 let too_new = keep_newer_than.is_some_and(|cutoff| {
123 std::fs::metadata(path)
124 .and_then(|m| m.modified())
125 .is_ok_and(|mtime| mtime >= cutoff)
126 });
127 if too_new {
128 stats.kept += 1;
129 }
130 !too_new
131 })
132 .collect();
133 if candidates.is_empty() {
134 reachable.require_roots_exist()?;
135 return Ok(stats);
136 }
137
138 let reachable = reachable.get()?;
139 for (oid, path) in candidates {
140 if reachable.contains(&oid) {
141 stats.kept += 1;
142 continue;
143 }
144
145 if dry_run {
146 stats.pruned += 1;
147 continue;
148 }
149 match std::fs::remove_file(&path) {
150 Ok(()) => stats.pruned += 1,
151 Err(e) if e.kind() == std::io::ErrorKind::NotFound => stats.pruned += 1,
153 Err(e) => return Err(e.into()),
154 }
155 }
156
157 Ok(stats)
158}
159
160pub fn collect_referenced_object_roots(git_dir: &Path) -> Result<Vec<ObjectId>> {
169 let mut roots = HashSet::new();
170 for (_, oid) in refs::list_refs(git_dir, "refs/")? {
171 roots.insert(oid);
172 }
173 let head_path = git_dir.join("HEAD");
174 if head_path.is_file() {
175 match refs::read_ref_file(&head_path)? {
176 Ref::Direct(oid) => {
177 roots.insert(oid);
178 }
179 Ref::Symbolic(_) => {}
180 }
181 }
182 Ok(roots.into_iter().collect())
183}
184
185pub(crate) struct LazyReachable<'a> {
191 odb: &'a Odb,
192 roots: &'a [ObjectId],
193 shallow: &'a HashSet<ObjectId>,
194 closure: OnceCell<OidHashSet>,
195 promised: OnceCell<HashSet<ObjectId>>,
196}
197
198impl<'a> LazyReachable<'a> {
199 pub(crate) fn new(odb: &'a Odb, roots: &'a [ObjectId], shallow: &'a HashSet<ObjectId>) -> Self {
202 Self {
203 odb,
204 roots,
205 shallow,
206 closure: OnceCell::new(),
207 promised: OnceCell::new(),
208 }
209 }
210
211 pub(crate) fn get(&self) -> Result<&OidHashSet> {
218 if let Some(closure) = self.closure.get() {
219 return Ok(closure);
220 }
221 let closure = reachable_closure(self.odb, self.roots, self.shallow, self.promised()?)?;
222 Ok(self.closure.get_or_init(|| closure))
223 }
224
225 pub(crate) fn require_roots_exist(&self) -> Result<()> {
227 if self.closure.get().is_some() {
228 return Ok(());
229 }
230 match require_objects_exist(self.odb, self.roots, &HashSet::new()) {
231 Err(Error::ObjectNotFound(_)) => {
232 require_objects_exist(self.odb, self.roots, self.promised()?)
233 }
234 result => result,
235 }
236 }
237
238 pub(crate) fn promised(&self) -> Result<&HashSet<ObjectId>> {
240 if let Some(promised) = self.promised.get() {
241 return Ok(promised);
242 }
243 let promised = crate::promisor::maintenance_promise_boundary(self.odb)?;
244 Ok(self.promised.get_or_init(|| promised))
245 }
246}
247
248pub(crate) const S_IFMT: u32 = 0o170000;
250pub(crate) const S_IFDIR: u32 = 0o040000;
251
252fn reachable_closure(
264 odb: &Odb,
265 roots: &[ObjectId],
266 shallow: &HashSet<ObjectId>,
267 promised: &HashSet<ObjectId>,
268) -> Result<OidHashSet> {
269 let mut seen: OidHashSet = promised.iter().copied().collect();
271 let mut queue: VecDeque<ObjectId> = VecDeque::new();
272 let oid_len = odb.hash_algo().len();
273 let mut blobs: Vec<ObjectId> = Vec::new();
274 let alternates = crate::alternate_boundary::AlternateSources::load(odb)?;
275
276 for &root in roots {
277 if seen.insert(root) && !alternates.contains(&root) {
278 queue.push_back(root);
279 }
280 }
281
282 while let Some(oid) = queue.pop_front() {
283 if alternates.contains(&oid) {
284 continue;
285 }
286 let obj = odb
287 .try_read_once(&oid)?
288 .ok_or_else(|| Error::ObjectNotFound(oid.to_hex()))?;
289 match obj.kind {
290 ObjectKind::Commit => {
291 let commit = parse_commit(&obj.data)?;
292 if !shallow.contains(&oid) {
293 for parent in commit.parents {
294 if seen.insert(parent) && !alternates.contains(&parent) {
295 queue.push_back(parent);
296 }
297 }
298 }
299 if seen.insert(commit.tree) && !alternates.contains(&commit.tree) {
300 queue.push_back(commit.tree);
301 }
302 }
303 ObjectKind::Tree => {
304 for entry in TreeRefIter::with_oid_len(&obj.data, oid_len) {
305 let entry = entry?;
306 if entry.mode == 0o160000 {
308 continue;
309 }
310 if !seen.insert(entry.oid) {
311 continue;
312 }
313 if alternates.contains(&entry.oid) {
314 continue;
315 }
316 if entry.mode & S_IFMT == S_IFDIR {
319 queue.push_back(entry.oid);
320 } else {
321 blobs.push(entry.oid);
322 }
323 }
324 }
325 ObjectKind::Tag => {
326 let tag = parse_tag(&obj.data)?;
327 if seen.insert(tag.object) && !alternates.contains(&tag.object) {
328 queue.push_back(tag.object);
329 }
330 }
331 ObjectKind::Blob => {}
332 }
333 }
334
335 require_objects_exist(odb, &blobs, promised)?;
336 Ok(seen)
337}
338
339fn require_objects_exist(odb: &Odb, oids: &[ObjectId], promised: &HashSet<ObjectId>) -> Result<()> {
345 let indexes = odb.local_pack_indexes().unwrap_or_default();
346 for oid in oids {
347 if promised.contains(oid) {
348 continue;
349 }
350 let packed = indexes.iter().any(|idx| idx.find_position(oid).is_some());
351 if !packed && !odb.contains(oid)? {
352 return Err(Error::ObjectNotFound(oid.to_hex()));
353 }
354 }
355 Ok(())
356}
357
358pub fn remote_default_branch_local(remote_git_dir: &Path) -> Result<Option<String>> {
381 let remote_odb =
382 Odb::new(&remote_git_dir.join("objects")).with_config_git_dir(remote_git_dir.to_path_buf());
383
384 let entries = crate::remote::list_refs_from_git_dir(
385 remote_git_dir,
386 Some(&remote_odb),
387 &crate::remote::ListRefsOptions {
388 symrefs: true,
389 ..Default::default()
390 },
391 )
392 .map_err(crate::error::Error::from)?;
393
394 for entry in &entries {
395 if entry.name == "HEAD" {
396 return Ok(entry
397 .symref_target
398 .as_ref()
399 .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned()));
400 }
401 }
402
403 Ok(None)
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
408pub struct RefTransactionItem {
409 pub name: String,
411 pub new_oid: Option<ObjectId>,
413 pub expected_old: Option<ObjectId>,
423}
424
425pub fn update_refs(git_dir: &Path, updates: &[RefTransactionItem]) -> Result<()> {
445 if updates.is_empty() {
446 return Ok(());
447 }
448 let store_updates: Vec<RefUpdate> = updates
449 .iter()
450 .map(ref_transaction_item_to_store_update)
451 .collect();
452 refs::commit_ref_store_batch(git_dir, &store_updates)
453}
454
455fn ref_transaction_item_to_store_update(item: &RefTransactionItem) -> RefUpdate {
456 let mut flags = RefUpdateFlags::default();
457 if item.name == "HEAD" {
458 flags.no_deref = true;
459 }
460 RefUpdate {
461 name: item.name.clone(),
462 new_value: item.new_oid.map(RawRef::Direct),
463 expected: match item.expected_old {
464 Some(oid) => Expected::Oid(oid),
465 None => Expected::Any,
466 },
467 reflog: None,
468 flags,
469 }
470}
471
472#[cfg(test)]
473mod update_refs_tests {
474 use super::*;
475 use crate::error::Error;
476 use crate::refs::store::RefStoreError;
477 use crate::repo::init_repository;
478 use tempfile::TempDir;
479
480 fn sample_oid(hex: &str) -> ObjectId {
481 hex.parse().expect("oid")
482 }
483
484 #[test]
485 fn rejects_duplicate_ref_names_in_batch() {
486 let tmp = TempDir::new().expect("tempdir");
487 let repo = init_repository(
488 tmp.path(),
489 false,
490 "main",
491 None,
492 crate::RefStorageFormat::Files,
493 )
494 .expect("init");
495 let oid = sample_oid("67bf698f3ab735e92fb011a99cff3497c44d30c1");
496 let items = vec![
497 RefTransactionItem {
498 name: "refs/heads/dup".to_owned(),
499 new_oid: Some(oid),
500 expected_old: None,
501 },
502 RefTransactionItem {
503 name: "refs/heads/dup".to_owned(),
504 new_oid: Some(oid),
505 expected_old: None,
506 },
507 ];
508 let err = update_refs(&repo.git_dir(), &items).expect_err("duplicate");
509 match &err {
510 Error::RefStore(RefStoreError::DuplicateUpdate { name }) => {
511 assert_eq!(name, "refs/heads/dup");
512 }
513 other => panic!("expected DuplicateUpdate, got {other}"),
514 }
515 }
516
517 #[test]
518 fn update_refs_typed_errors() {
519 let tmp = TempDir::new().expect("tempdir");
520 let repo = init_repository(
521 tmp.path(),
522 false,
523 "main",
524 None,
525 crate::RefStorageFormat::Files,
526 )
527 .expect("init");
528 let git_dir = repo.git_dir();
529 let c1 = refs::test_commit(&git_dir, "c1");
530 let c2 = refs::test_commit(&git_dir, "c2");
531 refs::write_ref(&git_dir, "refs/heads/cas", &c2).expect("seed");
532
533 let err = update_refs(
534 &git_dir,
535 &[RefTransactionItem {
536 name: "refs/heads/cas".to_owned(),
537 new_oid: Some(c1),
538 expected_old: Some(c1),
539 }],
540 )
541 .expect_err("stale cas");
542 match &err {
543 Error::RefStore(RefStoreError::ExpectedMismatch { name, .. }) => {
544 assert_eq!(name, "refs/heads/cas");
545 }
546 other => panic!("expected ExpectedMismatch, got {other}"),
547 }
548 }
549
550 #[test]
551 fn reftable_cas_rechecked_under_stack_lock() {
552 let tmp = TempDir::new().expect("tempdir");
553 let repo = init_repository(
554 tmp.path(),
555 false,
556 "main",
557 None,
558 crate::RefStorageFormat::Reftable,
559 )
560 .expect("init");
561 let git_dir = repo.git_dir();
562 let c1 = refs::test_commit(&git_dir, "c1");
563 let c2 = refs::test_commit(&git_dir, "c2");
564 crate::refs::write_ref(&git_dir, "refs/heads/locked-cas", &c1).expect("seed");
565 crate::refs::write_ref(&git_dir, "refs/heads/locked-cas", &c2).expect("advance");
566 let err = update_refs(
567 &git_dir,
568 &[RefTransactionItem {
569 name: "refs/heads/locked-cas".to_owned(),
570 new_oid: Some(c1),
571 expected_old: Some(c1),
572 }],
573 )
574 .expect_err("stale cas");
575 assert!(
576 matches!(err, Error::RefStore(RefStoreError::ExpectedMismatch { .. })),
577 "{err}"
578 );
579 assert_eq!(
580 crate::refs::resolve_ref(&git_dir, "refs/heads/locked-cas").unwrap(),
581 c2
582 );
583 }
584
585 #[cfg_attr(
586 not(feature = "full-tests"),
587 ignore = "starts system git or is heavy; run with --features full-tests"
588 )]
589 #[test]
590 fn apply_cas_rejects_after_concurrent_packed_delete() {
591 let _pause_serial = refs::test_pause_injection_serial_guard();
592 use std::path::Path;
593 use std::process::Command;
594 use std::sync::Arc;
595 use std::thread;
596
597 let _pause_scope = refs::ref_store_batch_pause_test_scope();
598
599 fn git(dir: &Path, args: &[&str]) {
600 let out = Command::new("git")
601 .current_dir(dir)
602 .args(args)
603 .env("GIT_AUTHOR_NAME", "T")
604 .env("GIT_AUTHOR_EMAIL", "t@example.com")
605 .env("GIT_COMMITTER_NAME", "T")
606 .env("GIT_COMMITTER_EMAIL", "t@example.com")
607 .env("GIT_CONFIG_GLOBAL", "/dev/null")
608 .env("GIT_CONFIG_SYSTEM", "/dev/null")
609 .output()
610 .expect("git");
611 assert!(
612 out.status.success(),
613 "git {:?}: {}",
614 args,
615 String::from_utf8_lossy(&out.stderr)
616 );
617 }
618
619 fn rev_parse(repo: &Path, refname: &str) -> ObjectId {
620 let hex = Command::new("git")
621 .current_dir(repo)
622 .args(["rev-parse", refname])
623 .env("GIT_CONFIG_GLOBAL", "/dev/null")
624 .env("GIT_CONFIG_SYSTEM", "/dev/null")
625 .output()
626 .expect("rev-parse")
627 .stdout;
628 String::from_utf8(hex)
629 .expect("utf8")
630 .trim()
631 .parse()
632 .expect("oid")
633 }
634
635 let tmp = TempDir::new().expect("tempdir");
636 let repo = tmp.path().join("repo");
637 std::fs::create_dir_all(&repo).unwrap();
638 git(&repo, &["init", "-q", "-b", "main", "."]);
639 let git_dir = repo.join(".git");
640
641 std::fs::write(repo.join("f.txt"), "one\n").unwrap();
642 git(&repo, &["add", "f.txt"]);
643 git(&repo, &["commit", "-q", "-m", "c1"]);
644 let c1 = rev_parse(&repo, "HEAD");
645
646 std::fs::write(repo.join("f.txt"), "two\n").unwrap();
647 git(&repo, &["add", "f.txt"]);
648 git(&repo, &["commit", "-q", "-m", "c2"]);
649 let c2 = rev_parse(&repo, "HEAD");
650
651 for name in ["refs/heads/first", "refs/heads/middle", "refs/heads/last"] {
652 git(&repo, &["update-ref", name, &c1.to_hex()]);
653 }
654 git(&repo, &["pack-refs", "--all"]);
655 for name in ["first", "middle", "last"] {
656 let _ = std::fs::remove_file(git_dir.join(format!("refs/heads/{name}")));
657 }
658
659 let gate = refs::new_ref_store_batch_pause_gate();
660 refs::set_test_inject_ref_store_batch_pause_after_apply(
661 0,
662 &git_dir,
663 Some(Arc::clone(&gate)),
664 );
665
666 let updates = vec![
667 RefTransactionItem {
668 name: "refs/heads/first".to_owned(),
669 new_oid: Some(c2),
670 expected_old: Some(c1),
671 },
672 RefTransactionItem {
673 name: "refs/heads/middle".to_owned(),
674 new_oid: Some(c2),
675 expected_old: Some(c1),
676 },
677 RefTransactionItem {
678 name: "refs/heads/last".to_owned(),
679 new_oid: Some(c2),
680 expected_old: Some(c1),
681 },
682 ];
683
684 let git_dir_thread = git_dir.clone();
685 let updates_thread = updates.clone();
686 let worker = thread::spawn(move || update_refs(&git_dir_thread, &updates_thread));
687
688 let mut paused = gate.wait.lock().expect("lock");
689 while !*paused {
690 paused = gate.cv.wait(paused).expect("wait");
691 }
692
693 git(
694 &repo,
695 &["update-ref", "-d", "refs/heads/last", &c1.to_hex()],
696 );
697
698 *paused = false;
699 gate.cv.notify_all();
700 drop(paused);
701
702 let err = worker.join().expect("join").expect_err("stale CAS");
703 match &err {
704 Error::RefStore(RefStoreError::ExpectedMismatch { name, .. }) => {
705 assert_eq!(name, "refs/heads/last");
706 }
707 other => panic!("expected ExpectedMismatch for last ref, got {other}"),
708 }
709 assert!(crate::refs::resolve_ref(&git_dir, "refs/heads/last").is_err());
710 assert_eq!(
711 crate::refs::resolve_ref(&git_dir, "refs/heads/first").expect("first"),
712 c2
713 );
714
715 refs::set_test_inject_ref_store_batch_pause_after_apply(0, &git_dir, None);
716 }
717
718 #[cfg_attr(
719 not(feature = "full-tests"),
720 ignore = "starts system git or is heavy; run with --features full-tests"
721 )]
722 #[test]
723 fn unconditional_create_rejects_after_concurrent_packed_parent() {
724 let _pause_serial = refs::test_pause_injection_serial_guard();
725 use std::path::Path;
726 use std::process::Command;
727 use std::sync::Arc;
728 use std::thread;
729
730 let _pause_scope = refs::ref_store_batch_pause_test_scope();
731
732 fn git(dir: &Path, args: &[&str]) {
733 let out = Command::new("git")
734 .current_dir(dir)
735 .args(args)
736 .env("GIT_AUTHOR_NAME", "T")
737 .env("GIT_AUTHOR_EMAIL", "t@example.com")
738 .env("GIT_COMMITTER_NAME", "T")
739 .env("GIT_COMMITTER_EMAIL", "t@example.com")
740 .env("GIT_CONFIG_GLOBAL", "/dev/null")
741 .env("GIT_CONFIG_SYSTEM", "/dev/null")
742 .output()
743 .expect("git");
744 assert!(
745 out.status.success(),
746 "git {:?}: {}",
747 args,
748 String::from_utf8_lossy(&out.stderr)
749 );
750 }
751
752 fn rev_parse(repo: &Path, refname: &str) -> ObjectId {
753 let hex = Command::new("git")
754 .current_dir(repo)
755 .args(["rev-parse", refname])
756 .env("GIT_CONFIG_GLOBAL", "/dev/null")
757 .env("GIT_CONFIG_SYSTEM", "/dev/null")
758 .output()
759 .expect("rev-parse")
760 .stdout;
761 String::from_utf8(hex)
762 .expect("utf8")
763 .trim()
764 .parse()
765 .expect("oid")
766 }
767
768 let tmp = TempDir::new().expect("tempdir");
769 let repo = tmp.path().join("repo");
770 std::fs::create_dir_all(&repo).unwrap();
771 git(&repo, &["init", "-q", "-b", "main", "."]);
772 let git_dir = repo.join(".git");
773
774 std::fs::write(repo.join("f.txt"), "one\n").unwrap();
775 git(&repo, &["add", "f.txt"]);
776 git(&repo, &["commit", "-q", "-m", "c1"]);
777 let c1 = rev_parse(&repo, "HEAD");
778
779 let gate = refs::new_ref_store_batch_pause_gate();
780 refs::set_test_inject_ref_store_batch_pause_after_apply(
781 0,
782 &git_dir,
783 Some(Arc::clone(&gate)),
784 );
785
786 let updates = vec![
787 RefTransactionItem {
788 name: "refs/heads/one".to_owned(),
789 new_oid: Some(c1),
790 expected_old: None,
791 },
792 RefTransactionItem {
793 name: "refs/heads/two".to_owned(),
794 new_oid: Some(c1),
795 expected_old: None,
796 },
797 RefTransactionItem {
798 name: "refs/heads/conflict/child".to_owned(),
799 new_oid: Some(c1),
800 expected_old: None,
801 },
802 ];
803
804 let git_dir_thread = git_dir.clone();
805 let updates_thread = updates.clone();
806 let worker = thread::spawn(move || update_refs(&git_dir_thread, &updates_thread));
807
808 let mut paused = gate.wait.lock().expect("lock");
809 while !*paused {
810 paused = gate.cv.wait(paused).expect("wait");
811 }
812
813 git(&repo, &["update-ref", "refs/heads/conflict", &c1.to_hex()]);
814 git(&repo, &["pack-refs", "--all"]);
815
816 *paused = false;
817 gate.cv.notify_all();
818 drop(paused);
819
820 let err = worker
821 .join()
822 .expect("join")
823 .expect_err("namespace conflict on child");
824 assert!(
825 err.to_string().contains("namespace conflict")
826 || err.to_string().contains("cannot lock ref"),
827 "{err}"
828 );
829 assert!(crate::refs::resolve_ref(&git_dir, "refs/heads/conflict").is_ok());
830 assert!(crate::refs::resolve_ref(&git_dir, "refs/heads/conflict/child").is_err());
831
832 refs::set_test_inject_ref_store_batch_pause_after_apply(0, &git_dir, None);
833 }
834}
835
836#[cfg(test)]
837mod prune_walk_tests {
838 use super::*;
839 use crate::maintenance::{repack, RepackOptions};
840 use crate::repo::init_repository;
841 use tempfile::TempDir;
842
843 #[test]
844 fn promise_boundary_is_shared_and_missing_local_objects_still_fail() {
845 let tmp = TempDir::new().expect("tempdir");
846 let repo = init_repository(
847 tmp.path(),
848 true,
849 "main",
850 None,
851 crate::RefStorageFormat::Files,
852 )
853 .expect("init");
854 let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").expect("oid");
855 let mut tree = b"100644 promised\0".to_vec();
856 tree.extend_from_slice(missing.as_bytes());
857 let tree = repo.odb().write(ObjectKind::Tree, &tree).expect("tree");
858 crate::promisor::write_promisor_marker(repo.git_dir(), &HashSet::from([missing]))
859 .expect("marker");
860 let roots = [tree];
861 let shallow = HashSet::new();
862 let reachable = LazyReachable::new(repo.odb(), &roots, &shallow);
863 assert!(reachable.get().expect("closure").contains(&missing));
864 crate::promisor::write_promisor_marker(repo.git_dir(), &HashSet::new())
865 .expect("clear marker");
866 assert!(reachable
867 .promised()
868 .expect("cached promises")
869 .contains(&missing));
870 let next = LazyReachable::new(repo.odb(), &roots, &shallow);
871 assert!(matches!(next.get(), Err(Error::ObjectNotFound(_))));
872 }
873
874 #[test]
875 fn prune_checks_packed_blobs_without_per_object_lookups() {
876 let tmp = TempDir::new().expect("tempdir");
877 let repo = init_repository(
878 tmp.path(),
879 false,
880 "main",
881 None,
882 crate::RefStorageFormat::Files,
883 )
884 .expect("init");
885 let odb = repo.odb();
886 let mut tree = Vec::new();
887 for i in 0..50 {
888 let blob = odb
889 .write(ObjectKind::Blob, format!("blob {i}\n").as_bytes())
890 .expect("blob");
891 tree.extend_from_slice(format!("100644 f{i:02}\0").as_bytes());
892 tree.extend_from_slice(blob.as_bytes());
893 }
894 let tree = odb.write(ObjectKind::Tree, &tree).expect("tree");
895 let commit = format!(
896 "tree {tree}\nauthor A <a@example.com> 1 +0000\ncommitter A <a@example.com> 1 +0000\n\nm\n"
897 );
898 let commit = odb
899 .write(ObjectKind::Commit, commit.as_bytes())
900 .expect("commit");
901 std::fs::write(
902 repo.git_dir().join("refs/heads/main"),
903 format!("{}\n", commit.to_hex()),
904 )
905 .expect("ref");
906 repack(&repo, &RepackOptions::default()).expect("repack");
907 assert!(odb
908 .enumerate_local_loose_objects()
909 .expect("loose")
910 .is_empty());
911
912 odb.reset_exists_probe();
913 let stats = prune_loose_unreachable(odb, &[commit], None).expect("prune");
914 assert_eq!(stats, PruneStats::default());
915 assert_eq!(
916 odb.exists_probe_count(),
917 0,
918 "packed blobs must be checked against the pack indexes, not one lookup each"
919 );
920 }
921}