1use std::collections::{HashMap, HashSet};
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use std::time::{Instant, SystemTime};
22
23use crate::alternate_boundary::AlternateSources;
24use crate::commit_graph_file::CommitGraphChain;
25use crate::commit_graph_write::{
26 atomic_replace_commit_graph, build_commit_graph_bytes_from_oid_maps,
27 collect_reachable_commit_oids_for_repository, load_commit_graph_commit_info,
28 remove_commit_graph_chain_dir, remove_commit_graph_files, BloomWriteStats,
29};
30use crate::config::{canonical_key, parse_bool, ConfigSet};
31use crate::diff::bloom::BloomFilterSettings;
32use crate::environment::Environment;
33use crate::error::{ConfigError, Error, Result};
34use crate::fsck as repo_fsck;
35use crate::gc::{prune_loose_unreachable_with, LazyReachable, PruneStats, S_IFDIR, S_IFMT};
36use crate::git_date::approx::approxidate_careful_at;
37use crate::index_pack::{install_pack_bytes, install_pack_path, IngestPackOptions};
38use crate::midx::{
39 clear_pack_midx_state, midx_version_from_config, midx_write_version,
40 write_multi_pack_index_with_options, MidxBitmapWriteOutcome, WriteMultiPackIndexOptions,
41 WriteMultiPackIndexResult,
42};
43use crate::objects::{
44 oid_hash_map, tag_object_line_oid, CommitRef, FileMode, ObjectId, ObjectKind, OidHashSet,
45 TreeRefIter,
46};
47use crate::odb::Odb;
48use crate::odb::UnreachablePackRetention;
49use crate::pack_bitmap::{PackBitmapWriteError, PackBitmapWriteOptions, PackBitmapWriter};
50use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
51use crate::pack_name_hash::pack_name_hash_child;
52use crate::pack_object_select::{
53 enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
54};
55use crate::pack_objects::{PackBuildOptions, SerializedDeltaPack};
56use crate::pack_rev::write_pack_rev_sidecar_for_index_path;
57use crate::prune_packed::{prune_packed_objects, PrunePackedOptions};
58use crate::reflog::{
59 expire_reflog_git_with_cache, load_gc_reflog_expire_config, reflog_refs_on_store,
60 ReflogExpireParams, ReflogExpiryCache,
61};
62use crate::repack_pack::{write_repack_pack, RepackPackSettings};
63use crate::repo::Repository;
64use crate::shallow::load_shallow_boundaries;
65use time::OffsetDateTime;
66
67mod drop_blobs;
68
69pub use crate::offload::{
70 offload_blobs, LopReadiness, OffloadConfigChange, OffloadError, OffloadOptions, OffloadPack,
71 OffloadPackRole, OffloadRepo, OffloadReport, LOP_GIT_DIR_KEY, LOP_KEEP_MESSAGE,
72};
73pub use drop_blobs::{
74 drop_large_blobs_enabled, drop_promised_blobs, DropExpiry, DropPromisedBlobsOptions,
75 DropPromisedBlobsReport, DropPromisedBlobsSkip, DroppedBlob, KeptBlobCounts,
76 DEFAULT_DROP_EXPIRE, DEFAULT_DROP_MIN_SIZE, DROP_LARGE_BLOBS_TASK,
77};
78
79#[derive(Debug, Clone)]
81#[non_exhaustive]
82pub struct RepackOptions {
83 pub dry_run: bool,
85 pub write_commit_graph: bool,
87 pub write_bitmap: bool,
89 pub prune_older_than: Option<OffsetDateTime>,
91 pub now: Option<OffsetDateTime>,
93 pub keep_unreachable: bool,
95 pub threads: Option<usize>,
97 pub pack_build: Option<PackBuildOptions>,
104 pub merged_config: Option<ConfigSet>,
106 pub environment: Environment,
108 pub incremental: bool,
117 pub auto_skip: bool,
121 pub force_recompress: bool,
125}
126
127impl Default for RepackOptions {
128 fn default() -> Self {
129 Self {
130 dry_run: false,
131 write_commit_graph: false,
132 write_bitmap: false,
133 prune_older_than: None,
134 now: None,
135 keep_unreachable: true,
136 threads: None,
137 pack_build: None,
138 merged_config: None,
139 environment: Environment::empty(),
140 incremental: false,
141 auto_skip: false,
142 force_recompress: false,
143 }
144 }
145}
146
147impl RepackOptions {
148 #[must_use]
150 pub fn with_dry_run(dry_run: bool) -> Self {
151 Self {
152 dry_run,
153 ..Self::default()
154 }
155 }
156}
157
158struct RepackDetail {
159 report: RepackReport,
160 pack_path: Option<PathBuf>,
161 prune_packed_loose: usize,
162 unreachable_loose: PruneStats,
163 unreachable_prune_ms: u64,
164 unreachable_prune_ran: bool,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
169#[non_exhaustive]
170pub struct RepackReport {
171 pub packs_written: usize,
173 pub packs_removed: usize,
175 pub loose_removed: usize,
177 pub objects: usize,
179}
180
181#[derive(Debug, Clone, Default, PartialEq, Eq)]
183#[non_exhaustive]
184pub struct RepackStats {
185 pub loose_before: usize,
187 pub packs_before: usize,
189 pub objects_packed: Option<usize>,
192 pub pack_bytes: Option<u64>,
194 pub pack_path: Option<PathBuf>,
197 pub removed_pack_files: usize,
199 pub pruned_loose: usize,
201 pub unreachable_loose_pruned: usize,
203 pub unreachable_loose_kept: usize,
205 pub unreachable_prune_ms: u64,
207 pub unreachable_prune_ran: bool,
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
213#[non_exhaustive]
214pub struct CommitGraphOptions {
215 pub changed_paths: bool,
217 pub dry_run: bool,
219}
220
221impl CommitGraphOptions {
222 #[must_use]
224 pub fn with_changed_paths(changed_paths: bool) -> Self {
225 Self {
226 changed_paths,
227 ..Self::default()
228 }
229 }
230
231 #[must_use]
233 pub fn with_dry_run(dry_run: bool) -> Self {
234 Self {
235 dry_run,
236 ..Self::default()
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243#[non_exhaustive]
244pub struct CommitGraphWriteReport {
245 pub path: PathBuf,
247 pub commit_count: usize,
249 pub removed_chain: bool,
251 pub bloom_stats: Option<BloomWriteStats>,
254 pub skipped: Option<CommitGraphSkipReason>,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264#[non_exhaustive]
265pub enum CommitGraphSkipReason {
266 Shallow,
268 Grafts,
270 ReplaceRefs,
272}
273
274impl CommitGraphSkipReason {
275 #[must_use]
277 pub fn as_str(self) -> &'static str {
278 match self {
279 Self::Shallow => "shallow",
280 Self::Grafts => "grafts",
281 Self::ReplaceRefs => "replace-refs",
282 }
283 }
284
285 pub(crate) fn detect(
291 git_dir: &Path,
292 has_replace_refs: impl FnOnce() -> Result<bool>,
293 ) -> Result<Option<Self>> {
294 let common = crate::repo::common_git_dir_for_config(git_dir);
295 if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
296 return Ok(Some(Self::Shallow));
297 }
298 if !crate::rev_parse::load_graft_parents(&common).is_empty() {
299 return Ok(Some(Self::Grafts));
300 }
301 Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
302 }
303
304 pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
307 Self::detect(repo.git_dir(), || {
308 let Some(base) = repo.replace_ref_base_if_enabled() else {
309 return Ok(false);
310 };
311 Ok(!repo.references().list(&base)?.is_empty())
312 })
313 }
314}
315
316#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
318#[non_exhaustive]
319pub struct MidxOptions {
320 pub bitmap: bool,
322 pub dry_run: bool,
324 pub version: Option<u8>,
328}
329
330impl MidxOptions {
331 #[must_use]
333 pub fn with_bitmap(bitmap: bool) -> Self {
334 Self {
335 bitmap,
336 ..Self::default()
337 }
338 }
339
340 #[must_use]
342 pub fn with_dry_run(dry_run: bool) -> Self {
343 Self {
344 dry_run,
345 ..Self::default()
346 }
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
352#[non_exhaustive]
353pub struct MidxWriteReport {
354 pub path: PathBuf,
356 pub pack_count: usize,
358 pub bitmap: MidxBitmapWriteOutcome,
360}
361
362#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
364#[non_exhaustive]
365pub struct PackRefsOptions {
366 pub all: bool,
368 pub dry_run: bool,
370}
371
372impl PackRefsOptions {
373 #[must_use]
375 pub fn with_all(all: bool) -> Self {
376 Self {
377 all,
378 ..Self::default()
379 }
380 }
381
382 #[must_use]
384 pub fn with_dry_run(dry_run: bool) -> Self {
385 Self {
386 dry_run,
387 ..Self::default()
388 }
389 }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
394#[non_exhaustive]
395pub struct PackRefsReport {
396 pub path: PathBuf,
398 pub refs_packed: usize,
400 pub refs_pruned: usize,
402}
403
404#[derive(Debug, Clone)]
406#[non_exhaustive]
407pub struct GcOptions {
408 pub repack: RepackOptions,
410 pub expire_reflog: bool,
412 pub now_unix: i64,
414 pub pack_refs: bool,
416 pub write_commit_graph: bool,
421 pub drop_large_blobs: bool,
427}
428
429impl Default for GcOptions {
430 fn default() -> Self {
431 Self {
432 repack: RepackOptions {
433 auto_skip: true,
434 ..RepackOptions::default()
435 },
436 expire_reflog: true,
437 now_unix: 0,
438 pack_refs: true,
439 write_commit_graph: true,
440 drop_large_blobs: true,
441 }
442 }
443}
444
445impl GcOptions {
446 #[must_use]
448 pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
449 Self {
450 repack: RepackOptions {
451 dry_run,
452 auto_skip: true,
453 ..RepackOptions::default()
454 },
455 expire_reflog: true,
456 now_unix,
457 ..Self::default()
458 }
459 }
460
461 #[must_use]
463 pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
464 Self {
465 repack: RepackOptions {
466 auto_skip: true,
467 ..RepackOptions::default()
468 },
469 expire_reflog,
470 now_unix,
471 ..Self::default()
472 }
473 }
474
475 #[must_use]
477 pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
478 Self {
479 repack: RepackOptions {
480 dry_run,
481 auto_skip: true,
482 ..RepackOptions::default()
483 },
484 expire_reflog: true,
485 now_unix,
486 pack_refs: true,
487 write_commit_graph: true,
488 ..Self::default()
489 }
490 }
491}
492
493impl PruneOptions {
494 #[must_use]
496 pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
497 Self {
498 older_than,
499 ..Self::default()
500 }
501 }
502}
503
504pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
513 repo_fsck::reachability_roots(repo)
514}
515
516pub(crate) fn bloom_settings_for_commit_graph_write(
517 config: &ConfigSet,
518 changed_paths: bool,
519) -> BloomFilterSettings {
520 let mut bloom = BloomFilterSettings::default();
521 if !changed_paths {
522 return bloom;
523 }
524 if let Some(v) = config
525 .get("commitgraph.changedpathsversion")
526 .and_then(|s| s.trim().parse::<i32>().ok())
527 {
528 if v == 2 {
529 bloom.hash_version = 2;
530 } else if v == 1 {
531 bloom.hash_version = 1;
532 }
533 }
534 bloom
535}
536
537fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
543 repo.odb()
544 .files_objects_dir()
545 .and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
546 .is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
547}
548
549pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
550 config
551 .get("commitgraph.generationversion")
552 .and_then(|s| s.trim().parse::<i32>().ok())
553 .map(|v| v >= 2)
554 .unwrap_or(true)
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
559#[non_exhaustive]
560pub struct BuiltCommitGraph {
561 pub bytes: Vec<u8>,
563 pub commit_count: usize,
565 pub bloom_stats: Option<BloomWriteStats>,
567}
568
569impl BuiltCommitGraph {
570 pub fn to_chain(&self) -> Result<CommitGraphChain> {
578 CommitGraphChain::from_bytes(self.bytes.clone())
579 }
580}
581
582#[derive(Debug, Clone, PartialEq, Eq)]
584pub enum CommitGraphBuild {
585 Built(BuiltCommitGraph),
587 Skipped(CommitGraphSkipReason),
589}
590
591pub fn build_commit_graph(
613 repo: &Repository,
614 opts: &CommitGraphOptions,
615) -> Result<CommitGraphBuild> {
616 if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
617 return Ok(CommitGraphBuild::Skipped(reason));
618 }
619 let odb = repo.odb();
620 let existing = odb.commit_graph_chain();
621 let commits = collect_reachable_commit_oids_for_repository(repo)?;
622 build_commit_graph_for_commits(
623 odb,
624 commits,
625 &*repo.config()?,
626 opts.changed_paths,
627 existing.as_deref(),
628 )
629 .map(CommitGraphBuild::Built)
630}
631
632pub(crate) fn build_commit_graph_for_commits(
638 odb: &Odb,
639 commits: impl IntoIterator<Item = ObjectId>,
640 config: &ConfigSet,
641 changed_paths: bool,
642 existing: Option<&CommitGraphChain>,
643) -> Result<BuiltCommitGraph> {
644 let bloom = bloom_settings_for_commit_graph_write(config, changed_paths);
645 let write_generation = commit_graph_writes_generation_data(config);
646 let mut sorted: Vec<ObjectId> = commits.into_iter().collect();
647 sorted.sort_unstable();
648
649 let mut infos = oid_hash_map();
650 for oid in &sorted {
651 infos.insert(*oid, load_commit_graph_commit_info(odb, *oid)?);
652 }
653
654 let mut existing_filters = oid_hash_map();
655 let mut upgraded_filters = oid_hash_map();
656 if let (true, Some(chain)) = (changed_paths, existing) {
657 for oid in &sorted {
658 if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
659 existing_filters.insert(*oid, bytes);
660 } else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
661 upgraded_filters.insert(*oid, bytes);
662 }
663 }
664 }
665
666 let (bytes, bloom_stats) = build_commit_graph_bytes_from_oid_maps(
667 &sorted,
668 &infos,
669 odb,
670 changed_paths,
671 &bloom,
672 None,
673 &[],
674 None,
675 &existing_filters,
676 &upgraded_filters,
677 write_generation,
678 )?;
679 Ok(BuiltCommitGraph {
680 bytes,
681 commit_count: sorted.len(),
682 bloom_stats: changed_paths.then_some(bloom_stats),
683 })
684}
685
686pub fn write_commit_graph(
705 repo: &Repository,
706 opts: &CommitGraphOptions,
707) -> Result<CommitGraphWriteReport> {
708 let odb = repo.odb();
709 odb.require_files_primary("commit-graph write")?;
710 let objects_dir = odb
711 .files_objects_dir()
712 .ok_or(Error::UnsupportedObjectStore {
713 operation: "commit-graph write",
714 })?;
715 let path = objects_dir.join("info").join("commit-graph");
716
717 if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
718 let removed_chain = if opts.dry_run {
719 false
720 } else {
721 let removed = remove_commit_graph_files(objects_dir)?;
722 repo.caches().invalidate_commit_graph_cache();
723 removed
724 };
725 return Ok(CommitGraphWriteReport {
726 path,
727 commit_count: 0,
728 removed_chain,
729 bloom_stats: None,
730 skipped: Some(reason),
731 });
732 }
733
734 let commits = collect_reachable_commit_oids_for_repository(repo)?;
735 if opts.dry_run {
736 return Ok(CommitGraphWriteReport {
737 path,
738 commit_count: commits.len(),
739 removed_chain: false,
740 bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
741 skipped: None,
742 });
743 }
744
745 let existing = CommitGraphChain::try_load(objects_dir).ok().flatten();
746 let built = build_commit_graph_for_commits(
747 odb,
748 commits,
749 &*repo.config()?,
750 opts.changed_paths,
751 existing.as_ref(),
752 )?;
753
754 let written_path = atomic_replace_commit_graph(objects_dir, &built.bytes)?;
755 let removed_chain = remove_commit_graph_chain_dir(objects_dir);
756 repo.caches().invalidate_commit_graph_cache();
757
758 Ok(CommitGraphWriteReport {
759 path: written_path,
760 commit_count: built.commit_count,
761 removed_chain,
762 bloom_stats: built.bloom_stats,
763 skipped: None,
764 })
765}
766
767pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
777 let odb = repo.odb();
778 odb.require_files_primary("midx write")?;
779 let requested = match opts.version {
780 Some(v) => Some(v),
781 None => midx_version_from_config(&*repo.config()?)?,
782 };
783 let version = Some(midx_write_version(requested)?);
784 let objects_dir = odb
785 .files_objects_dir()
786 .ok_or(Error::UnsupportedObjectStore {
787 operation: "midx write",
788 })?;
789 let pack_dir = objects_dir.join("pack");
790 let pack_count = fs::read_dir(&pack_dir)
791 .map(|rd| {
792 rd.filter_map(|e| e.ok())
793 .filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
794 .filter(|e| e.path().with_extension("pack").is_file())
795 .count()
796 })
797 .unwrap_or(0);
798
799 let path = pack_dir.join("multi-pack-index");
800 if opts.dry_run {
801 return Ok(MidxWriteReport {
802 path,
803 pack_count,
804 bitmap: MidxBitmapWriteOutcome::NotRequested,
805 });
806 }
807 let write_opts = WriteMultiPackIndexOptions {
808 write_bitmap: opts.bitmap,
809 version,
810 ..WriteMultiPackIndexOptions::default()
811 };
812 let WriteMultiPackIndexResult { bitmap } =
813 write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
814 Ok(MidxWriteReport {
815 path,
816 pack_count,
817 bitmap,
818 })
819}
820
821#[derive(Debug, Clone, Default)]
823#[non_exhaustive]
824pub struct PruneOptions {
825 pub older_than: Option<SystemTime>,
829 pub dry_run: bool,
831}
832
833#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
835#[non_exhaustive]
836pub struct GcPhaseTimings {
837 pub pack_refs_ms: u64,
839 pub reflog_ms: u64,
841 pub repack_ms: u64,
843 pub prune_ms: u64,
845 pub commit_graph_ms: u64,
847 pub drop_large_blobs_ms: u64,
849}
850
851#[derive(Debug, Clone, Default, PartialEq, Eq)]
853pub struct GcStats {
854 pub pack_refs: Option<PackRefsReport>,
856 pub repack: RepackStats,
858 pub prune: PruneStats,
860 pub prune_ran: bool,
862 pub commit_graph: Option<CommitGraphWriteReport>,
864 pub reflog_entries_pruned: usize,
866 pub drop_large_blobs: Option<DropPromisedBlobsReport>,
870 pub phase_timings: GcPhaseTimings,
872}
873
874pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
888 Ok(repack_detail(repo, opts)?.report)
889}
890
891fn repack_detail(repo: &Repository, opts: &RepackOptions) -> Result<RepackDetail> {
892 let odb = repo.odb();
893 odb.require_files_primary("repack")?;
894 let objects_dir = odb
895 .files_objects_dir()
896 .ok_or(Error::UnsupportedObjectStore {
897 operation: "repack",
898 })?;
899 let pack_dir = objects_dir.join("pack");
900
901 let mut report = RepackReport::default();
902 let loose_before = count_loose_objects(odb)?;
903 let packs_before = count_pack_files(&pack_dir);
904 let pack_inventory = pack_repack_inventory(&pack_dir);
905
906 #[cfg(test)]
907 gc_test_hooks::run(gc_test_hooks::GcPausePoint::AfterInventoryBeforeRoots);
908
909 let roots = collect_maintenance_object_roots(repo)?;
910
911 #[cfg(test)]
912 gc_test_hooks::run(gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll);
913 if roots.is_empty() && loose_before == 0 && packs_before == 0 {
914 return Ok(RepackDetail {
915 report,
916 pack_path: None,
917 prune_packed_loose: 0,
918 unreachable_loose: PruneStats::default(),
919 unreachable_prune_ms: 0,
920 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
921 });
922 }
923
924 let cfg = opts
925 .merged_config
926 .clone()
927 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
928 let mut pack_opts = opts
929 .pack_build
930 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
931 if opts.force_recompress {
932 pack_opts.reuse_deltas = false;
933 }
934
935 let run_incremental = opts.incremental;
936 let shallow = load_shallow_boundaries(repo.git_dir());
937 let reachable = LazyReachable::new(odb, &roots, &shallow);
938
939 let skip_repack = !opts.force_recompress
940 && !opts.write_bitmap
941 && !opts.write_commit_graph
942 && should_auto_skip_repack(
943 loose_before,
944 packs_before,
945 run_incremental,
946 opts.auto_skip,
947 cfg.as_ref(),
948 )
949 && !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
950
951 if skip_repack {
952 reachable.require_roots_exist()?;
955 return Ok(RepackDetail {
956 report,
957 pack_path: None,
958 prune_packed_loose: 0,
959 unreachable_loose: PruneStats::default(),
960 unreachable_prune_ms: 0,
961 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
962 });
963 }
964 let promised = reachable.promised()?;
965 let partial_clone = !promised.is_empty()
966 || cfg.as_ref().is_some_and(|config| {
967 crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config)
968 });
969 if partial_clone {
970 reachable.get()?;
973 }
974 if opts.dry_run {
975 report.objects = if run_incremental {
976 reachable_loose_objects(odb, &reachable)?.len()
977 } else {
978 if !opts.keep_unreachable {
979 reachable
980 .get()?
981 .iter()
982 .filter(|oid| !promised.contains(oid))
983 .count()
984 } else {
985 repack_object_list(odb, &roots, &shallow, true, pack_opts.use_bitmaps, promised)?
986 .send
987 .len()
988 }
989 };
990 return Ok(RepackDetail {
991 report,
992 pack_path: None,
993 prune_packed_loose: 0,
994 unreachable_loose: PruneStats::default(),
995 unreachable_prune_ms: 0,
996 unreachable_prune_ran: false,
997 });
998 }
999
1000 let retained_packs = if run_incremental {
1001 HashSet::new()
1002 } else {
1003 packs_holding_available_promises(odb, promised)?
1004 };
1005 let built_pack = if run_incremental {
1006 let (loose, name_hashes) = named_reachable_loose_objects(odb, &reachable, &roots)?;
1007 write_staged_repack_pack(
1008 odb,
1009 &pack_dir,
1010 &loose,
1011 &name_hashes,
1012 &pack_opts,
1013 opts.threads,
1014 cfg.as_ref(),
1015 )?
1016 } else {
1017 build_repack_pack(
1018 odb,
1019 &pack_dir,
1020 &roots,
1021 &shallow,
1022 opts.keep_unreachable,
1023 &pack_opts,
1024 opts.threads,
1025 cfg.as_ref(),
1026 promised,
1027 )?
1028 };
1029 let pack_bytes_len = built_pack
1030 .staging_path
1031 .as_ref()
1032 .and_then(|p| std::fs::metadata(p).ok())
1033 .map(|m| m.len() as usize)
1034 .unwrap_or_else(|| built_pack.bytes.len());
1035 let mut prune_packed_loose = 0usize;
1036 let mut unreachable_loose = PruneStats::default();
1037 let mut installed_pack = None;
1038 let pack_trailer_len = 12 + odb.hash_algo().len();
1039 if pack_bytes_len > pack_trailer_len {
1040 clear_pack_midx_state(&pack_dir)?;
1041
1042 let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
1043 let ingest_opts = IngestPackOptions {
1044 fix_thin: false,
1045 threads: opts.threads,
1046 merged_config: cfg.clone(),
1047 environment: opts.environment.clone(),
1048 skip_post_index_verify: trusted_rows.is_some(),
1050 trusted_index_rows: trusted_rows,
1051 ..IngestPackOptions::default()
1052 };
1053 let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
1054 install_pack_path(staging, odb, &ingest_opts)?
1055 } else {
1056 install_pack_bytes(built_pack.bytes, odb, &ingest_opts)?
1057 };
1058 report.packs_written = 1;
1059 report.objects = ingested.object_ids.len();
1060 let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
1061 operation: "repack",
1062 })?;
1063
1064 let idx_path = pack_path.with_extension("idx");
1065 if cfg
1066 .as_ref()
1067 .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
1068 {
1069 write_pack_rev_sidecar_for_index_path(&idx_path)?;
1070 }
1071
1072 if opts.write_bitmap && promised.is_empty() && !AlternateSources::load(odb)?.in_use() {
1073 if opts.incremental && packs_before > 0 {
1074 let version = match cfg.as_ref() {
1078 Some(c) => midx_version_from_config(c)?,
1079 None => None,
1080 };
1081 let midx_opts = WriteMultiPackIndexOptions {
1082 write_bitmap: true,
1083 version,
1084 ..WriteMultiPackIndexOptions::default()
1085 };
1086 let WriteMultiPackIndexResult { bitmap: _ } =
1087 write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
1088 } else {
1089 let bitmap_opts = PackBitmapWriteOptions {
1090 reachability_roots: Some(roots.clone()),
1091 ..PackBitmapWriteOptions::default()
1092 };
1093 let now = opts
1094 .now
1095 .map(offset_to_system_time)
1096 .unwrap_or(SystemTime::UNIX_EPOCH);
1097 match PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now) {
1098 Ok(_) => {}
1099 Err(PackBitmapWriteError::NotClosed { .. }) => {
1100 }
1103 Err(e) => return Err(Error::CorruptObject(e.to_string())),
1104 }
1105 }
1106 }
1107
1108 let retention = if opts.keep_unreachable {
1109 UnreachablePackRetention::KeepAll
1110 } else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1111 UnreachablePackRetention::KeepGraceProtected(cutoff)
1112 } else {
1113 UnreachablePackRetention::DropAll
1114 };
1115 if !opts.incremental {
1117 odb.loosen_unreachable_from_superseded_packs(
1118 &pack_dir,
1119 &pack_path,
1120 &reachable,
1121 retention,
1122 &retained_packs,
1123 &pack_inventory,
1124 )?;
1125 }
1126
1127 odb.invalidate_packs();
1129
1130 report.packs_removed = if opts.incremental {
1131 0
1132 } else {
1133 remove_superseded_pack_files_retaining(
1134 &pack_dir,
1135 &pack_path,
1136 &retained_packs,
1137 &pack_inventory,
1138 )?
1139 };
1140
1141 prune_packed_loose =
1142 prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
1143
1144 installed_pack = Some(pack_path);
1145
1146 if opts.write_commit_graph {
1147 write_commit_graph(
1148 repo,
1149 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
1150 repo,
1151 )),
1152 )?;
1153 }
1154 }
1155
1156 let mut unreachable_prune_ms = 0u64;
1157 let mut unreachable_prune_ran = false;
1158 if !opts.keep_unreachable {
1159 let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
1160 let prune_start = Instant::now();
1161 unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
1162 unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
1163 unreachable_prune_ran = true;
1164 }
1165
1166 report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
1167 Ok(RepackDetail {
1168 report,
1169 pack_path: installed_pack,
1170 prune_packed_loose,
1171 unreachable_loose,
1172 unreachable_prune_ms,
1173 unreachable_prune_ran,
1174 })
1175}
1176
1177pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
1179 let odb = repo.odb();
1180 let objects_dir = odb
1181 .files_objects_dir()
1182 .ok_or(Error::UnsupportedObjectStore {
1183 operation: "repack",
1184 })?;
1185 let pack_dir = objects_dir.join("pack");
1186 let mut stats = RepackStats {
1187 loose_before: count_loose_objects(odb)?,
1188 packs_before: count_pack_files(&pack_dir),
1189 ..RepackStats::default()
1190 };
1191 if opts.dry_run {
1192 let count = repack_detail(repo, opts)?.report.objects;
1193 stats.objects_packed = (count > 0).then_some(count);
1194 return Ok(stats);
1195 }
1196 let detail = repack_detail(repo, opts)?;
1197 let report = detail.report;
1198 stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
1199 stats.removed_pack_files = report.packs_removed;
1200 stats.pruned_loose = detail.prune_packed_loose;
1201 stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
1202 stats.unreachable_loose_kept = detail.unreachable_loose.kept;
1203 stats.unreachable_prune_ms = detail.unreachable_prune_ms;
1204 stats.unreachable_prune_ran = detail.unreachable_prune_ran;
1205 stats.pack_path = detail.pack_path;
1206 stats.pack_bytes = stats
1207 .pack_path
1208 .as_ref()
1209 .and_then(|p| p.metadata().ok().map(|m| m.len()));
1210 Ok(stats)
1211}
1212
1213fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
1214 SystemTime::UNIX_EPOCH
1215 + std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
1216}
1217
1218fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
1219 match policy {
1220 GcLoosePrunePolicy::Disabled => {
1221 repack.keep_unreachable = true;
1222 }
1223 GcLoosePrunePolicy::Enabled { keep_newer_than } => {
1224 repack.keep_unreachable = false;
1225 repack.prune_older_than = keep_newer_than.map(|t| {
1226 let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
1227 OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
1228 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
1229 });
1230 }
1231 }
1232}
1233
1234fn config_bool_or_error(config: &ConfigSet, key: &str) -> Result<Option<bool>> {
1235 match config.get_bool(key) {
1236 None => Ok(None),
1237 Some(Ok(v)) => Ok(Some(v)),
1238 Some(Err(e)) => Err(Error::Config(ConfigError::Other(format!(
1239 "bad config value for '{key}': {e}"
1240 )))),
1241 }
1242}
1243
1244fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1246 let repack_key = canonical_key("repack.writeBitmaps").ok();
1247 let pack_key = canonical_key("pack.writeBitmaps").ok();
1248 let mut last: Option<bool> = None;
1249 for entry in config.entries() {
1250 let key = entry.key.as_str();
1251 if repack_key.as_deref() == Some(key) || pack_key.as_deref() == Some(key) {
1252 let raw = entry.value.as_deref().unwrap_or("true").trim().to_owned();
1253 last = Some(parse_bool(&raw).map_err(|_| {
1254 Error::Config(ConfigError::Other(format!(
1255 "bad config value '{raw}' for '{key}'"
1256 )))
1257 })?);
1258 }
1259 }
1260 Ok(last.unwrap_or_else(|| repo.work_tree().is_none()))
1261}
1262
1263fn gc_should_pack_refs(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1264 let Some(spec) = config.get("gc.packRefs") else {
1265 return Ok(true);
1266 };
1267 if spec.eq_ignore_ascii_case("notbare") {
1268 return Ok(repo.work_tree().is_some());
1269 }
1270 parse_bool(&spec).map_err(|e| {
1271 Error::Config(ConfigError::Other(format!(
1272 "bad config value '{spec}' for 'gc.packRefs': {e}"
1273 )))
1274 })
1275}
1276
1277fn gc_should_write_commit_graph(config: &ConfigSet) -> Result<bool> {
1278 if config_bool_or_error(config, "gc.writeCommitGraph")? == Some(false) {
1279 return Ok(false);
1280 }
1281 Ok(config_bool_or_error(config, "core.commitGraph")? != Some(false))
1282}
1283
1284fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1287 let loose = odb.enumerate_local_loose_objects()?;
1288 if loose.is_empty() {
1289 return Ok(Vec::new());
1290 }
1291 let promised = reachable.promised()?;
1292 let reachable = reachable.get()?;
1293 let alternates = AlternateSources::load(odb)?;
1294 Ok(loose
1295 .into_iter()
1296 .map(|(oid, _)| oid)
1297 .filter(|oid| {
1298 reachable.contains(oid) && !promised.contains(oid) && !alternates.contains(oid)
1299 })
1300 .collect())
1301}
1302
1303fn named_reachable_loose_objects(
1311 odb: &Odb,
1312 reachable: &LazyReachable<'_>,
1313 roots: &[ObjectId],
1314) -> Result<(Vec<ObjectId>, HashMap<ObjectId, u32>)> {
1315 let loose = reachable_loose_objects(odb, reachable)?;
1316 let mut pending: OidHashSet = loose.iter().copied().collect();
1317 let mut order = Vec::with_capacity(loose.len());
1318 let mut names = HashMap::new();
1319 let mut commits = Vec::new();
1320 let mut root_trees = Vec::new();
1321 let mut stack: Vec<ObjectId> = roots
1322 .iter()
1323 .filter(|oid| pending.contains(*oid))
1324 .copied()
1325 .collect();
1326 let mut seen = OidHashSet::default();
1327 while let Some(oid) = stack.pop() {
1328 if !seen.insert(oid) {
1329 continue;
1330 }
1331 match odb.read_info(&oid)?.kind {
1332 ObjectKind::Commit => {
1333 let obj = odb.read(&oid)?;
1334 let commit = CommitRef::parse(&obj.data)?;
1335 commits.push((
1336 commit.committer_unix_seconds_for_ordering(),
1337 oid,
1338 commit.tree(),
1339 ));
1340 stack.extend(commit.parents().iter().filter(|p| pending.contains(*p)));
1341 }
1342 ObjectKind::Tag => {
1343 let obj = odb.read(&oid)?;
1344 pending.remove(&oid);
1345 order.push(oid);
1346 stack.extend(tag_object_line_oid(&obj.data).filter(|t| pending.contains(t)));
1347 }
1348 ObjectKind::Tree => root_trees.push(oid),
1349 ObjectKind::Blob => {}
1350 }
1351 }
1352 commits.sort_by_key(|&(time, _, _)| std::cmp::Reverse(time));
1353 for &(_, oid, _) in &commits {
1354 pending.remove(&oid);
1355 order.push(oid);
1356 }
1357 let hash_len = odb.hash_algo().len();
1358 let mut trees: Vec<(ObjectId, Option<u32>)> = Vec::new();
1359 for tree in commits.iter().map(|&(_, _, tree)| tree).chain(root_trees) {
1360 trees.push((tree, None));
1361 while let Some((tree, name_hash)) = trees.pop() {
1362 if !pending.remove(&tree) {
1363 continue;
1364 }
1365 order.push(tree);
1366 if let Some(hash) = name_hash {
1367 names.insert(tree, hash);
1368 }
1369 let obj = odb.read(&tree)?;
1370 for entry in TreeRefIter::with_oid_len(&obj.data, hash_len) {
1371 let entry = entry?;
1372 if FileMode::new(entry.mode).is_gitlink() || !pending.contains(&entry.oid) {
1373 continue;
1374 }
1375 let hash = pack_name_hash_child(name_hash, entry.name);
1376 if entry.mode & S_IFMT == S_IFDIR {
1377 trees.push((entry.oid, Some(hash)));
1378 } else {
1379 pending.remove(&entry.oid);
1380 order.push(entry.oid);
1381 names.insert(entry.oid, hash);
1382 }
1383 }
1384 }
1385 }
1386 order.extend(loose.into_iter().filter(|oid| pending.contains(oid)));
1387 Ok((order, names))
1388}
1389
1390fn should_auto_skip_repack(
1395 loose_objects: usize,
1396 pack_count: usize,
1397 incremental: bool,
1398 auto_skip: bool,
1399 _config: Option<&ConfigSet>,
1400) -> bool {
1401 if !auto_skip || incremental {
1402 return false;
1403 }
1404 if pack_count > 1 {
1405 return false;
1406 }
1407 if pack_count == 1 {
1408 return loose_objects == 0;
1409 }
1410 loose_objects == 0
1411}
1412
1413fn skipping_repack_would_retain_prunable_packed_objects(
1418 odb: &Odb,
1419 pack_dir: &Path,
1420 reachable: &LazyReachable<'_>,
1421 opts: &RepackOptions,
1422) -> Result<bool> {
1423 if opts.keep_unreachable {
1424 return Ok(false);
1425 }
1426 let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1427 UnreachablePackRetention::KeepGraceProtected(cutoff)
1428 } else {
1429 UnreachablePackRetention::DropAll
1430 };
1431 let indexes = odb.local_pack_indexes()?;
1432 for idx in indexes {
1433 if idx.is_promisor || pack_has_keep_marker(pack_dir, &idx.pack_path) {
1434 continue;
1435 }
1436 let pack_mtime = fs::metadata(&idx.pack_path)
1437 .and_then(|m| m.modified())
1438 .unwrap_or(SystemTime::UNIX_EPOCH);
1439 let drop_unreachable_from_pack = match retention {
1440 UnreachablePackRetention::KeepAll => false,
1441 UnreachablePackRetention::DropAll => true,
1442 UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1443 };
1444 if !drop_unreachable_from_pack {
1445 continue;
1446 }
1447 let reachable = reachable.get()?;
1448 for entry in idx.iter() {
1449 let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1450 continue;
1451 };
1452 if !reachable.contains(&oid) {
1453 return Ok(true);
1454 }
1455 }
1456 }
1457 Ok(false)
1458}
1459
1460pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1466 let odb = repo.odb();
1467 odb.require_files_primary("prune")?;
1468 let cfg = repo.config()?;
1469 let now = gc_now_unix(0);
1470 let keep_newer_than = match opts.older_than {
1471 Some(t) => Some(t),
1472 None => match gc_loose_prune_policy(&cfg, now)? {
1473 GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1474 GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1475 },
1476 };
1477 let roots = collect_maintenance_object_roots(repo)?;
1478 let shallow = load_shallow_boundaries(repo.git_dir());
1479 prune_loose_unreachable_with(
1480 odb,
1481 &LazyReachable::new(odb, &roots, &shallow),
1482 keep_newer_than,
1483 opts.dry_run,
1484 )
1485}
1486
1487fn duration_ms(d: std::time::Duration) -> u64 {
1488 d.as_millis() as u64
1489}
1490
1491pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1501 repo.odb().require_files_primary("gc")?;
1502 let now = gc_now_unix(opts.now_unix);
1503 let _lock = if opts.repack.dry_run {
1504 None
1505 } else {
1506 match GcLock::try_acquire(repo, now)? {
1507 GcLockAttempt::Acquired(lock) => Some(lock),
1508 GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1509 }
1510 };
1511 gc_locked(repo, opts, now)
1512}
1513
1514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1516pub enum AutoGcReason {
1517 TooManyLoose {
1520 estimate: usize,
1522 limit: usize,
1524 },
1525 TooManyPacks {
1528 count: usize,
1530 limit: usize,
1532 },
1533}
1534
1535const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1537
1538pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1553 let cfg = repo.config()?;
1554 let Some(threshold) = cfg.gc_auto_threshold() else {
1555 return Ok(None);
1556 };
1557 let Some(objects_dir) = repo.odb().files_objects_dir() else {
1558 return Ok(None);
1559 };
1560 if let Some(limit) = cfg.gc_auto_pack_limit() {
1561 let count = count_pack_files(&objects_dir.join("pack"));
1562 if count > limit {
1563 return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1564 }
1565 }
1566 let name_len = repo.odb().hash_algo().hex_len() - 2;
1567 let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1568 if sampled > threshold.div_ceil(256) {
1569 return Ok(Some(AutoGcReason::TooManyLoose {
1570 estimate: sampled.saturating_mul(256),
1571 limit: threshold,
1572 }));
1573 }
1574 Ok(None)
1575}
1576
1577fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1579 let entries = match fs::read_dir(dir) {
1580 Ok(entries) => entries,
1581 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1582 Err(e) => return Err(Error::Io(e)),
1583 };
1584 Ok(entries
1585 .filter_map(|e| e.ok())
1586 .filter(|e| {
1587 let name = e.file_name();
1588 let name = name.as_encoded_bytes();
1589 name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1590 })
1591 .count())
1592}
1593
1594#[derive(Debug, Clone, PartialEq, Eq)]
1596#[non_exhaustive]
1597pub enum AutoGcOutcome {
1598 NotNeeded,
1600 AlreadyRunning(GcLockHolder),
1602 DeclinedByHook {
1604 status: i32,
1606 output: crate::hooks::HookOutput,
1608 },
1609 Ran {
1611 reason: AutoGcReason,
1613 stats: Box<GcStats>,
1615 },
1616}
1617
1618pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1638 let Some(reason) = auto_gc_needed(repo)? else {
1639 return Ok(AutoGcOutcome::NotNeeded);
1640 };
1641 let now = gc_now_unix(opts.now_unix);
1642 let dry_run = opts.repack.dry_run;
1643 let _lock = if dry_run {
1644 None
1645 } else {
1646 match GcLock::try_acquire(repo, now)? {
1647 GcLockAttempt::Acquired(lock) => Some(lock),
1648 GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1649 }
1650 };
1651 if !dry_run {
1652 if let crate::hooks::HookResult::Failed { status, output } =
1653 crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1654 {
1655 return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1656 }
1657 }
1658 let mut run_opts = opts.clone();
1659 if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1660 run_opts.repack.incremental = true;
1661 run_opts.repack.write_bitmap = false;
1662 }
1663 let stats = gc_locked(repo, &run_opts, now)?;
1664 Ok(AutoGcOutcome::Ran {
1665 reason,
1666 stats: Box::new(stats),
1667 })
1668}
1669
1670#[derive(Debug, Clone, PartialEq, Eq)]
1672pub struct GcLockHolder {
1673 pub pid: Option<u64>,
1675 pub host: Option<String>,
1677}
1678
1679#[derive(Debug)]
1681#[must_use]
1682pub enum GcLockAttempt {
1683 Acquired(GcLock),
1685 Held(GcLockHolder),
1687}
1688
1689const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1691
1692#[derive(Debug)]
1698pub struct GcLock {
1699 path: PathBuf,
1700}
1701
1702impl GcLock {
1703 pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1711 let path = repo.common_git_dir().join("gc.pid");
1712 let mut lock = match crate::lockfile::LockFile::acquire(
1713 &path.with_extension("pid.lock"),
1714 crate::lockfile::LockWait::Immediately,
1715 ) {
1716 Ok(lock) => lock,
1717 Err(crate::lockfile::LockError::Held { .. }) => {
1718 return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1719 GcLockHolder {
1720 pid: None,
1721 host: None,
1722 },
1723 )));
1724 }
1725 Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1726 };
1727 let host = local_host_name();
1728 if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1729 lock.rollback();
1730 return Ok(GcLockAttempt::Held(holder));
1731 }
1732 use std::io::Write as _;
1733 write!(lock, "{} {host}", std::process::id())?;
1734 lock.commit_to(&path).map_err(|e| match e {
1735 crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1736 held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1737 })?;
1738 Ok(GcLockAttempt::Acquired(GcLock { path }))
1739 }
1740
1741 #[must_use]
1747 pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1748 let path = repo.common_git_dir().join("gc.pid");
1749 live_gc_pid_holder(&path, &local_host_name(), now_unix)
1750 }
1751}
1752
1753impl Drop for GcLock {
1754 fn drop(&mut self) {
1755 let _ = fs::remove_file(&self.path);
1756 }
1757}
1758
1759fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1760 let raw = fs::read_to_string(path).ok()?;
1761 let mut fields = raw.split_whitespace();
1762 let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1763 let host = fields.next().map(str::to_owned);
1764 Some(GcLockHolder { pid, host })
1765}
1766
1767fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1769 let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1770 let mtime = OffsetDateTime::from(mtime);
1771 let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1772 if now - mtime > GC_PID_STALE_AFTER {
1773 return None;
1774 }
1775 let holder = read_gc_pid(path)?;
1776 let pid = holder.pid?;
1777 let same_host = holder.host.as_deref() == Some(host);
1778 if same_host && !crate::lockfile::is_process_running(pid) {
1779 return None;
1780 }
1781 Some(holder)
1782}
1783
1784fn local_host_name() -> String {
1787 #[cfg(unix)]
1788 {
1789 if let Ok(uts) = nix::sys::utsname::uname() {
1790 if let Some(name) = uts.nodename().to_str() {
1791 return name.to_owned();
1792 }
1793 }
1794 }
1795 #[cfg(windows)]
1796 {
1797 if let Some(name) = windows_dns_host_name() {
1798 return name;
1799 }
1800 }
1801 "localhost".to_owned()
1802}
1803
1804#[cfg(windows)]
1806fn windows_dns_host_name() -> Option<String> {
1807 use windows_sys::Win32::System::SystemInformation::{
1808 ComputerNameDnsHostname, GetComputerNameExW,
1809 };
1810 let mut len: u32 = 0;
1811 unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1813 if len == 0 {
1814 return None;
1815 }
1816 let mut buf = vec![0u16; len as usize];
1817 let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1819 (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1820}
1821
1822fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
1823 let mut stats = GcStats::default();
1824 let cfg = repo.config()?;
1825 let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1826 let drop_plan = if opts.drop_large_blobs {
1828 Some(drop_blobs::plan_drop_for_gc(&cfg, opts.now_unix)?)
1829 } else {
1830 None
1831 };
1832 let dry_run = opts.repack.dry_run;
1833
1834 if opts.pack_refs && gc_should_pack_refs(repo, &cfg)? && !dry_run {
1835 let start = Instant::now();
1836 stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1837 stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1838 }
1839
1840 if opts.expire_reflog {
1841 let start = Instant::now();
1842 stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
1843 stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
1844 }
1845
1846 let mut repack_opts = opts.repack.clone();
1847 apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
1848 if repack_opts.now.is_none() {
1849 repack_opts.now =
1850 Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
1851 }
1852 if !repack_opts.write_bitmap && !repack_opts.incremental {
1855 repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg)?;
1856 }
1857 let repack_start = Instant::now();
1858 stats.repack = repack_all(repo, &repack_opts)?;
1859 let repack_total_ms = duration_ms(repack_start.elapsed());
1860 stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
1861 stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
1862
1863 if let Some(plan) = drop_plan {
1865 let start = Instant::now();
1866 stats.drop_large_blobs = Some(drop_blobs::run_drop_for_gc(
1867 repo,
1868 &cfg,
1869 plan,
1870 dry_run,
1871 opts.repack.threads,
1872 )?);
1873 stats.phase_timings.drop_large_blobs_ms = duration_ms(start.elapsed());
1874 }
1875
1876 if dry_run {
1877 return Ok(stats);
1878 }
1879
1880 stats.prune_ran = stats.repack.unreachable_prune_ran;
1883 stats.prune = PruneStats {
1884 pruned: stats.repack.unreachable_loose_pruned,
1885 kept: stats.repack.unreachable_loose_kept,
1886 };
1887
1888 if opts.write_commit_graph && gc_should_write_commit_graph(&cfg)? {
1889 let start = Instant::now();
1890 stats.commit_graph = Some(write_commit_graph(
1891 repo,
1892 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
1893 )?);
1894 stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
1895 }
1896 Ok(stats)
1897}
1898
1899enum GcLoosePrunePolicy {
1901 Disabled,
1903 Enabled {
1905 keep_newer_than: Option<SystemTime>,
1907 },
1908}
1909
1910fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
1911 let spec = config
1912 .get("gc.pruneExpire")
1913 .map(|s| s.trim().to_owned())
1914 .filter(|s| !s.is_empty())
1915 .unwrap_or_else(|| "2.weeks.ago".to_owned());
1916 if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
1917 return Ok(GcLoosePrunePolicy::Disabled);
1918 }
1919 if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
1921 return Ok(GcLoosePrunePolicy::Enabled {
1922 keep_newer_than: None,
1923 });
1924 }
1925 let mut err = 0;
1926 let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
1927 if err != 0 {
1928 return Err(Error::Config(ConfigError::Other(format!(
1929 "bad config value '{spec}' for 'gc.pruneExpire'"
1930 ))));
1931 }
1932 let keep_newer_than = SystemTime::UNIX_EPOCH
1933 .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
1934 Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
1935}
1936
1937fn gc_now_unix(now_unix: i64) -> i64 {
1938 if now_unix != 0 {
1939 return now_unix;
1940 }
1941 time::OffsetDateTime::now_utc().unix_timestamp()
1942}
1943
1944fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
1945 let cfg = repo.config()?;
1946 let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
1947 let params = ReflogExpireParams {
1948 stale_fix: false,
1949 dry_run,
1950 verbose: false,
1951 };
1952 let mut total = 0usize;
1953 let cache = ReflogExpiryCache::default();
1954 for refname in reflog_refs_on_store(repo.refs())? {
1955 let result = expire_reflog_git_with_cache(
1956 repo,
1957 &refname,
1958 ¶ms,
1959 None,
1960 None,
1961 &gc_cfg.patterns,
1962 gc_cfg.global_total,
1963 gc_cfg.global_unreachable,
1964 now,
1965 &cache,
1966 )?;
1967 total += result.pruned;
1968 }
1969 Ok(total)
1970}
1971
1972fn count_loose_objects(odb: &Odb) -> Result<usize> {
1973 Ok(odb.enumerate_local_loose_objects()?.len())
1974}
1975
1976#[allow(clippy::too_many_arguments)]
1979fn build_repack_pack(
1980 odb: &Odb,
1981 pack_dir: &Path,
1982 roots: &[ObjectId],
1983 shallow: &HashSet<ObjectId>,
1984 keep_unreachable: bool,
1985 opts: &PackBuildOptions,
1986 threads: Option<usize>,
1987 cfg: Option<&ConfigSet>,
1988 promised: &HashSet<ObjectId>,
1989) -> Result<SerializedDeltaPack> {
1990 let selection = repack_object_list(
1991 odb,
1992 roots,
1993 shallow,
1994 keep_unreachable,
1995 opts.use_bitmaps,
1996 promised,
1997 )?;
1998 write_staged_repack_pack(
1999 odb,
2000 pack_dir,
2001 &selection.send,
2002 &selection.name_hashes,
2003 opts,
2004 threads,
2005 cfg,
2006 )
2007}
2008
2009pub(crate) fn write_staged_repack_pack(
2022 odb: &Odb,
2023 pack_dir: &Path,
2024 objects: &[ObjectId],
2025 name_hashes: &HashMap<ObjectId, u32>,
2026 opts: &PackBuildOptions,
2027 threads: Option<usize>,
2028 cfg: Option<&ConfigSet>,
2029) -> Result<SerializedDeltaPack> {
2030 if objects.is_empty() {
2031 return Ok(SerializedDeltaPack {
2032 bytes: Vec::new(),
2033 index_rows: Vec::new(),
2034 staging_path: None,
2035 });
2036 }
2037 odb.clear_delta_base_cache();
2038 let max_depth = if opts.delta { opts.max_depth } else { 0 };
2039 let settings = RepackPackSettings {
2040 window: opts.window,
2041 max_depth,
2042 reuse_deltas: opts.reuse_deltas,
2043 reuse_objects: opts.reuse_objects,
2044 use_ofs_delta: opts.use_ofs_delta,
2045 window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
2046 search_threads: pack_delta_search_parallel_options(odb, threads)?
2047 .threads
2048 .get(),
2049 write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
2050 };
2051 let git_dir = odb
2052 .config_git_dir()
2053 .map(Path::to_path_buf)
2054 .unwrap_or_else(|| {
2055 pack_dir
2056 .parent()
2057 .and_then(|objects| objects.parent())
2058 .map(Path::to_path_buf)
2059 .unwrap_or_else(|| pack_dir.to_path_buf())
2060 });
2061 crate::git_dir::io_error::io_with_git_path(
2062 &git_dir,
2063 pack_dir,
2064 crate::git_dir::GitDirPathOperation::GcPack,
2065 fs::create_dir_all(pack_dir),
2066 )?;
2067 let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
2068 let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
2069 match written {
2070 Ok(index_rows) => Ok(SerializedDeltaPack {
2071 bytes: Vec::new(),
2072 index_rows,
2073 staging_path: Some(staging),
2074 }),
2075 Err(err) => {
2076 let _ = fs::remove_file(&staging);
2077 Err(err)
2078 }
2079 }
2080}
2081
2082fn repack_object_list(
2086 odb: &Odb,
2087 roots: &[ObjectId],
2088 shallow: &HashSet<ObjectId>,
2089 keep_unreachable: bool,
2090 use_bitmaps: bool,
2091 promised: &HashSet<ObjectId>,
2092) -> Result<PackObjectSelection> {
2093 let mut roots: Vec<_> = roots
2096 .iter()
2097 .copied()
2098 .filter(|oid| !promised.contains(oid))
2099 .collect();
2100 roots.sort_unstable();
2101 let alternates = AlternateSources::load(odb)?;
2102 let use_bitmaps = use_bitmaps && promised.is_empty() && !alternates.in_use();
2103 let mut selection = enumerate_pack_objects_naming(
2104 odb,
2105 &roots,
2106 &[],
2107 &PackEnumerateOptions {
2108 filter: None,
2109 shallow_grafts: shallow,
2110 have_shallow_grafts: Some(shallow),
2111 use_bitmaps,
2112 exclude_objects: Some(promised),
2113 peer_odb: None,
2114 have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
2115 },
2116 NameTrees::Yes,
2117 true,
2118 &crate::progress::CancelSignal::never(),
2119 )?;
2120 if keep_unreachable {
2121 append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
2122 }
2123 Ok(selection)
2124}
2125
2126fn append_repack_unreachable_retention(
2128 odb: &Odb,
2129 send: &mut Vec<ObjectId>,
2130 promised: &HashSet<ObjectId>,
2131) -> Result<()> {
2132 let alternates = AlternateSources::load(odb)?;
2133 let mut in_send: OidHashSet = send.iter().copied().collect();
2134 odb.primary()?
2135 .packed_objects()
2136 .for_each_removable_pack_object(&mut |oid| {
2137 if !promised.contains(oid) && !alternates.contains(oid) && in_send.insert(*oid) {
2138 send.push(*oid);
2139 }
2140 std::ops::ControlFlow::Continue(())
2141 })?;
2142 for (oid, _) in odb.enumerate_local_loose_objects()? {
2143 if !promised.contains(&oid) && !alternates.contains(&oid) && in_send.insert(oid) {
2144 send.push(oid);
2145 }
2146 }
2147 Ok(())
2148}
2149
2150fn pack_repack_inventory(pack_dir: &Path) -> crate::odb::RepackPackInventorySnapshot {
2151 let pack_paths = local_pack_paths(pack_dir);
2152 let kept_at_inventory = pack_paths
2153 .iter()
2154 .filter(|pack_path| pack_has_keep_marker(pack_dir, pack_path))
2155 .cloned()
2156 .collect();
2157 crate::odb::RepackPackInventorySnapshot {
2158 pack_paths,
2159 kept_at_inventory,
2160 }
2161}
2162
2163fn local_pack_paths(pack_dir: &Path) -> HashSet<PathBuf> {
2165 let Ok(entries) = fs::read_dir(pack_dir) else {
2166 return HashSet::new();
2167 };
2168 entries
2169 .filter_map(|e| e.ok())
2170 .map(|e| pack_dir.join(e.file_name()))
2171 .filter(|p| p.extension().is_some_and(|e| e == "pack"))
2172 .collect()
2173}
2174
2175fn count_pack_files(pack_dir: &Path) -> usize {
2177 let Ok(entries) = fs::read_dir(pack_dir) else {
2178 return 0;
2179 };
2180 let mut packs = HashSet::new();
2181 let mut kept = HashSet::new();
2182 for entry in entries.filter_map(|e| e.ok()) {
2183 let path = PathBuf::from(entry.file_name());
2184 let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
2185 continue;
2186 };
2187 if ext == "pack" {
2188 packs.insert(stem.to_owned());
2189 } else if ext == "keep" {
2190 kept.insert(stem.to_owned());
2191 }
2192 }
2193 packs.difference(&kept).count()
2194}
2195
2196fn packs_holding_available_promises(
2200 odb: &Odb,
2201 promised: &HashSet<ObjectId>,
2202) -> Result<HashSet<PathBuf>> {
2203 if promised.is_empty() {
2204 return Ok(HashSet::new());
2205 }
2206 let indexes = odb.local_pack_indexes()?;
2207 let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
2208 let mut retained = HashSet::new();
2209 for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
2210 for entry in idx.iter() {
2211 let oid = ObjectId::from_bytes(entry.oid())?;
2212 if promised.contains(&oid)
2213 && !promisor_indexes
2214 .iter()
2215 .any(|idx| idx.find_position(&oid).is_some())
2216 {
2217 retained.insert(idx.pack_path.clone());
2218 break;
2219 }
2220 }
2221 }
2222 Ok(retained)
2223}
2224
2225fn remove_superseded_pack_files_retaining(
2228 pack_dir: &Path,
2229 keep_pack: &Path,
2230 retained_packs: &HashSet<PathBuf>,
2231 inventory: &crate::odb::RepackPackInventorySnapshot,
2232) -> Result<usize> {
2233 let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
2234 let keep_stem = keep_pack
2235 .file_stem()
2236 .and_then(|s| s.to_str())
2237 .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
2238 if !pack_dir.is_dir() {
2239 return Ok(0);
2240 }
2241 let mut removed = 0usize;
2242 for entry in crate::git_dir::io_error::io_with_git_path(
2243 &git_dir,
2244 pack_dir,
2245 crate::git_dir::GitDirPathOperation::GcPack,
2246 fs::read_dir(pack_dir),
2247 )? {
2248 let entry = entry.map_err(|source| {
2249 crate::git_dir::io_error::map_git_dir_io(
2250 &git_dir,
2251 pack_dir,
2252 crate::git_dir::GitDirPathOperation::GcPack,
2253 source,
2254 )
2255 })?;
2256 let name = entry.file_name();
2257 let name_str = name.to_string_lossy();
2258 if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2259 continue;
2260 }
2261 if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2262 continue;
2263 }
2264 let path = entry.path();
2265 if path.is_file() {
2266 let pack_path = path.with_extension("pack");
2267 if inventory.kept_at_inventory.contains(&pack_path)
2268 || path.with_extension("promisor").is_file()
2269 || retained_packs.contains(&pack_path)
2270 || !inventory.pack_paths.contains(&pack_path)
2271 {
2272 continue;
2273 }
2274 crate::git_dir::io_error::io_with_git_path(
2275 &git_dir,
2276 &path,
2277 crate::git_dir::GitDirPathOperation::GcPack,
2278 fs::remove_file(&path),
2279 )?;
2280 removed += 1;
2281 }
2282 }
2283 Ok(removed)
2284}
2285
2286#[derive(Debug, Clone, Default, PartialEq, Eq)]
2288pub(crate) struct PromisorRewrite {
2289 pub(crate) pack_path: Option<PathBuf>,
2292 pub(crate) objects: usize,
2294 pub(crate) dropped: usize,
2296 pub(crate) removed_pack_files: usize,
2298}
2299
2300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2302pub(crate) enum PromisorRewriteWhen {
2303 Dropping,
2305 Always,
2307}
2308
2309pub(crate) fn rewrite_local_objects_without(
2348 repo: &Repository,
2349 omit: &OidHashSet,
2350 opts: &RepackOptions,
2351 when: PromisorRewriteWhen,
2352) -> Result<PromisorRewrite> {
2353 let odb = repo.odb();
2354 odb.require_files_primary("promisor rewrite")?;
2355 if omit.is_empty() && when == PromisorRewriteWhen::Dropping {
2356 return Ok(PromisorRewrite::default());
2357 }
2358 let objects_dir = odb.objects_dir().to_path_buf();
2359 let pack_dir = objects_dir.join("pack");
2360 odb.reprepare_local_packs();
2361 let snapshot: Vec<_> = odb
2362 .local_pack_indexes()?
2363 .into_iter()
2364 .filter(|idx| !pack_has_keep_marker(&pack_dir, &idx.pack_path))
2365 .collect();
2366
2367 let mut send = Vec::new();
2368 let mut seen: OidHashSet = OidHashSet::default();
2369 let mut dropped: OidHashSet = OidHashSet::default();
2370 let mut take = |oid: ObjectId| {
2371 if omit.contains(&oid) {
2372 dropped.insert(oid);
2373 } else if seen.insert(oid) {
2374 send.push(oid);
2375 }
2376 };
2377 for idx in &snapshot {
2378 for entry in idx.iter() {
2379 take(ObjectId::from_bytes(entry.oid())?);
2380 }
2381 }
2382 let loose = odb.enumerate_local_loose_objects()?;
2383 for (oid, _) in &loose {
2384 take(*oid);
2385 }
2386 let folded = loose.is_empty() && snapshot.len() <= 1 && snapshot.iter().all(|i| i.is_promisor);
2387 let nothing_to_do = match when {
2388 PromisorRewriteWhen::Dropping => dropped.is_empty(),
2389 PromisorRewriteWhen::Always => dropped.is_empty() && folded,
2390 };
2391 if nothing_to_do {
2392 return Ok(PromisorRewrite::default());
2393 }
2394
2395 let cfg = opts
2396 .merged_config
2397 .clone()
2398 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
2399 let pack_opts = opts
2400 .pack_build
2401 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
2402 let built = write_staged_repack_pack(
2403 odb,
2404 &pack_dir,
2405 &send,
2406 &HashMap::new(),
2407 &pack_opts,
2408 opts.threads,
2409 cfg.as_ref(),
2410 )?;
2411 clear_pack_midx_state(&pack_dir)?;
2412 let mut rewrite = PromisorRewrite {
2413 dropped: dropped.len(),
2414 ..PromisorRewrite::default()
2415 };
2416 if let Some(staging) = built.staging_path.as_ref() {
2417 let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
2418 let ingested = install_pack_path(
2419 staging,
2420 odb,
2421 &IngestPackOptions {
2422 threads: opts.threads,
2423 merged_config: cfg.clone(),
2424 environment: opts.environment.clone(),
2425 skip_post_index_verify: trusted_rows.is_some(),
2426 trusted_index_rows: trusted_rows,
2427 mark_promisor_pack: true,
2428 ..IngestPackOptions::default()
2429 },
2430 )?;
2431 rewrite.objects = ingested.object_ids.len();
2432 if let Some(pack_path) = &ingested.pack_path {
2433 if cfg
2434 .as_ref()
2435 .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
2436 {
2437 write_pack_rev_sidecar_for_index_path(&pack_path.with_extension("idx"))?;
2438 }
2439 crate::index_pack::sync_installed_pack(pack_path)?;
2440 }
2441 rewrite.pack_path = ingested.pack_path;
2442 }
2443
2444 odb.invalidate_packs();
2446 let new_stem = rewrite
2447 .pack_path
2448 .as_deref()
2449 .and_then(Path::file_stem)
2450 .map(|s| s.to_os_string());
2451 let stems: HashSet<std::ffi::OsString> = snapshot
2452 .iter()
2453 .filter_map(|idx| idx.pack_path.file_stem().map(|s| s.to_os_string()))
2454 .filter(|stem| Some(stem) != new_stem.as_ref())
2455 .collect();
2456 drop(snapshot);
2457 rewrite.removed_pack_files = remove_pack_files_with_stems(&pack_dir, &stems)?;
2458 for oid in &dropped {
2459 odb.remove_local_loose(oid)?;
2460 }
2461 prune_packed_objects(&objects_dir, PrunePackedOptions::default())?;
2462 odb.invalidate_packs();
2463 Ok(rewrite)
2464}
2465
2466fn remove_pack_files_with_stems(
2470 pack_dir: &Path,
2471 stems: &HashSet<std::ffi::OsString>,
2472) -> Result<usize> {
2473 if stems.is_empty() {
2474 return Ok(0);
2475 }
2476 let mut removed = 0usize;
2477 for entry in fs::read_dir(pack_dir)?.filter_map(|e| e.ok()) {
2478 let path = entry.path();
2479 let Some(stem) = path.file_stem().filter(|stem| stems.contains(*stem)) else {
2480 continue;
2481 };
2482 if !path.is_file() || pack_dir.join(stem).with_extension("keep").is_file() {
2483 continue;
2484 }
2485 fs::remove_file(&path)?;
2486 removed += 1;
2487 }
2488 Ok(removed)
2489}
2490
2491fn pack_has_keep_marker(pack_dir: &Path, pack_path: &Path) -> bool {
2492 let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
2493 return false;
2494 };
2495 pack_dir.join(format!("{stem}.keep")).is_file()
2496}
2497
2498pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2507 let (path, refs_packed, refs_pruned) =
2508 crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2509 Ok(PackRefsReport {
2510 path,
2511 refs_packed,
2512 refs_pruned,
2513 })
2514}
2515
2516impl Repository {
2517 pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2523 write_commit_graph(self, opts)
2524 }
2525
2526 pub fn build_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphBuild> {
2532 build_commit_graph(self, opts)
2533 }
2534
2535 pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2541 write_midx(self, opts)
2542 }
2543}
2544
2545pub mod fsck {
2547 pub use crate::fsck_msg_policy::{
2548 FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2549 };
2550 pub use crate::fsck_standalone::*;
2551}
2552
2553pub mod repository_fsck {
2555 pub use crate::fsck::*;
2556}
2557
2558#[cfg(test)]
2559pub(crate) mod gc_test_hooks {
2560 use std::cell::RefCell;
2561
2562 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
2564 pub enum GcPausePoint {
2565 AfterInventoryBeforeRoots,
2567 AfterRootsBeforeDropAll,
2569 }
2570
2571 type GcHook = (GcPausePoint, Box<dyn FnOnce() + Send>);
2572
2573 thread_local! {
2574 static GC_HOOK: RefCell<Option<GcHook>> = const { RefCell::new(None) };
2575 }
2576
2577 pub fn on_next_gc(point: GcPausePoint, hook: impl FnOnce() + Send + 'static) {
2579 GC_HOOK.with(|slot| *slot.borrow_mut() = Some((point, Box::new(hook))));
2580 }
2581
2582 pub(crate) fn run(point: GcPausePoint) {
2583 let hook = GC_HOOK.with(|slot| {
2584 let mut slot = slot.borrow_mut();
2585 match slot.as_ref() {
2586 Some((at, _)) if *at == point => slot.take().map(|(_, hook)| hook),
2587 _ => None,
2588 }
2589 });
2590 if let Some(hook) = hook {
2591 hook();
2592 }
2593 }
2594}
2595
2596#[cfg(test)]
2597mod tests {
2598 #![allow(clippy::unwrap_used, clippy::expect_used)]
2599
2600 use super::*;
2601 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2602 use crate::porcelain::add::stage_for_commit;
2603 use crate::porcelain::commit::{create_commit, CommitRequest};
2604 use crate::progress::{NeverCancel, NullProgress};
2605 use crate::repo::InitOptions;
2606 use tempfile::TempDir;
2607
2608 fn pack_into(repo: &Repository, oids: &[ObjectId], keep: bool) -> PathBuf {
2611 let pack = crate::pack_objects::build_pack_from_send_list(
2612 repo.odb(),
2613 oids,
2614 &HashSet::new(),
2615 &PackBuildOptions::default(),
2616 &HashMap::new(),
2617 )
2618 .unwrap();
2619 install_pack_bytes(
2620 pack,
2621 repo.odb(),
2622 &IngestPackOptions {
2623 keep_message: keep.then(|| "pinned".to_owned()),
2624 ..IngestPackOptions::default()
2625 },
2626 )
2627 .unwrap()
2628 .pack_path
2629 .unwrap()
2630 }
2631
2632 #[test]
2633 fn promisor_rewrite_drops_only_the_omitted_objects() {
2634 let dir = TempDir::new().unwrap();
2635 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2636 let odb = repo.odb();
2637 let big = odb.write(ObjectKind::Blob, b"big blob").unwrap();
2638 let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2639 let kept = odb.write(ObjectKind::Blob, b"kept").unwrap();
2640 let unreachable = odb.write(ObjectKind::Blob, b"unreachable loose").unwrap();
2641 let mut body = b"100644 big\0".to_vec();
2642 body.extend_from_slice(big.as_bytes());
2643 let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2644 let old_pack = pack_into(&repo, &[packed, big, tree], false);
2645 let kept_pack = pack_into(&repo, &[kept], true);
2646 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2647 odb.write_loose_materialize(ObjectKind::Blob, b"big blob")
2649 .unwrap();
2650
2651 let omit: OidHashSet = [big, kept].into_iter().collect();
2652 let rewrite = rewrite_local_objects_without(
2653 &repo,
2654 &omit,
2655 &RepackOptions::default(),
2656 PromisorRewriteWhen::Dropping,
2657 )
2658 .unwrap();
2659 assert_eq!(rewrite.dropped, 1, "the kept copy is not ours to drop");
2660 assert_eq!(rewrite.objects, 3, "tree + packed + unreachable");
2661 let new_pack = rewrite.pack_path.expect("new pack");
2662 assert!(new_pack.with_extension("promisor").is_file());
2663 repo.odb().reprepare_local_packs();
2666 let indexes = repo.odb().local_pack_indexes().unwrap();
2667 let new_idx = indexes
2668 .iter()
2669 .find(|i| i.pack_path == new_pack)
2670 .expect("listed");
2671 for oid in [tree, packed, unreachable] {
2672 assert!(new_idx.find_position(&oid).is_some(), "{oid}");
2673 }
2674 assert!(!old_pack.exists());
2675 assert!(kept_pack.is_file() && kept_pack.with_extension("keep").is_file());
2676 assert!(rewrite.removed_pack_files >= 2, "old .pack and .idx");
2677
2678 let repo =
2679 Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2680 let odb = repo.odb();
2681 assert!(!odb.exists_stored(&big));
2682 assert!(odb.local_loose_file_size(&big).is_none());
2683 for oid in [packed, kept, unreachable, tree] {
2684 assert!(odb.exists_stored(&oid), "{oid} kept");
2685 }
2686 assert!(odb.enumerate_local_loose_objects().unwrap().is_empty());
2687 }
2688
2689 #[test]
2690 fn promisor_rewrite_without_anything_to_drop_changes_nothing() {
2691 let dir = TempDir::new().unwrap();
2692 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2693 let blob = repo.odb().write(ObjectKind::Blob, b"x").unwrap();
2694 let absent = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
2695 for omit in [OidHashSet::default(), [absent].into_iter().collect()] {
2696 let rewrite = rewrite_local_objects_without(
2697 &repo,
2698 &omit,
2699 &RepackOptions::default(),
2700 PromisorRewriteWhen::Dropping,
2701 )
2702 .unwrap();
2703 assert_eq!(rewrite, PromisorRewrite::default());
2704 }
2705 assert!(repo.odb().local_loose_file_size(&blob).is_some());
2706 }
2707
2708 #[test]
2709 fn promisor_rewrite_always_folds_until_one_promisor_pack_is_left() {
2710 let dir = TempDir::new().unwrap();
2711 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2712 let odb = repo.odb();
2713 let pushed = odb.write(ObjectKind::Blob, b"pushed").unwrap();
2714 let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2715 let mut body = b"100644 packed\0".to_vec();
2716 body.extend_from_slice(packed.as_bytes());
2717 let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2718 let tree_pack = pack_into(&repo, &[tree], false);
2721 fs::write(tree_pack.with_extension("promisor"), b"").unwrap();
2722 let blob_pack = pack_into(&repo, &[packed], false);
2723 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2724 odb.write_loose_materialize(ObjectKind::Blob, b"pushed")
2725 .unwrap();
2726 let fold = |repo: &Repository| {
2727 rewrite_local_objects_without(
2728 repo,
2729 &OidHashSet::default(),
2730 &RepackOptions::default(),
2731 PromisorRewriteWhen::Always,
2732 )
2733 .unwrap()
2734 };
2735 let first = fold(&repo);
2736 assert_eq!(first.dropped, 0);
2737 assert_eq!(first.objects, 3);
2738 let pack = first.pack_path.expect("folded pack");
2739 assert!(pack.with_extension("promisor").is_file());
2740 assert!(!tree_pack.exists() && !blob_pack.exists());
2741 let repo =
2742 Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2743 assert!(repo
2744 .odb()
2745 .enumerate_local_loose_objects()
2746 .unwrap()
2747 .is_empty());
2748 let indexes = repo.odb().local_pack_indexes().unwrap();
2749 assert_eq!(indexes.len(), 1);
2750 for oid in [pushed, packed, tree] {
2751 assert!(indexes[0].find_position(&oid).is_some(), "{oid}");
2752 }
2753 assert_eq!(fold(&repo), PromisorRewrite::default());
2755 assert!(pack.is_file());
2756 }
2757
2758 #[test]
2759 fn pack_removal_spares_unlisted_and_newly_kept_packs() {
2760 let dir = TempDir::new().unwrap();
2761 for name in [
2762 "pack-a.pack",
2763 "pack-a.idx",
2764 "pack-a.rev",
2765 "pack-b.pack",
2766 "pack-b.idx",
2767 "pack-c.pack",
2768 "pack-c.keep",
2769 ] {
2770 fs::write(dir.path().join(name), b"").unwrap();
2771 }
2772 let stems: HashSet<std::ffi::OsString> =
2773 ["pack-a", "pack-c"].into_iter().map(Into::into).collect();
2774 assert_eq!(remove_pack_files_with_stems(dir.path(), &stems).unwrap(), 3);
2775 let mut left: Vec<String> = fs::read_dir(dir.path())
2776 .unwrap()
2777 .flatten()
2778 .map(|e| e.file_name().to_string_lossy().into_owned())
2779 .collect();
2780 left.sort();
2781 assert_eq!(
2782 left,
2783 ["pack-b.idx", "pack-b.pack", "pack-c.keep", "pack-c.pack"]
2784 );
2785 }
2786
2787 #[cfg_attr(
2788 not(feature = "full-tests"),
2789 ignore = "starts system git or is heavy; run with --features full-tests"
2790 )]
2791 #[test]
2792 fn gc_auto_skip_single_pack_without_loose() {
2793 let dir = TempDir::new().unwrap();
2794 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2795 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2796 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2797 let ident = "Test <t@example.com> 1 +0000".to_string();
2798 create_commit(
2799 &repo,
2800 &CommitRequest {
2801 message: "init".into(),
2802 author: Some(ident.parse().expect("signature")),
2803 committer: Some(ident.parse().expect("signature")),
2804 allow_empty: false,
2805 sign_override: None,
2806 amend: false,
2807 assume_worktree_synced: false,
2808 skip_pre_commit_tree_match: false,
2809 staged_change_count: None,
2810 ..Default::default()
2811 },
2812 &mut NullProgress,
2813 &NeverCancel,
2814 )
2815 .expect("commit");
2816 git_cmd(dir.path(), &["repack", "-a", "-d"]);
2817 assert!(should_auto_skip_repack(0, 1, false, true, None));
2818 assert!(!should_auto_skip_repack(3, 1, false, true, None));
2819 assert!(!should_auto_skip_repack(0, 2, false, true, None));
2820 assert!(!should_auto_skip_repack(0, 2, false, false, None));
2821 }
2822
2823 #[test]
2824 fn count_pack_files_ignores_keep_markers() {
2825 let dir = TempDir::new().unwrap();
2826 let pack_dir = dir.path().join("pack");
2827 fs::create_dir_all(&pack_dir).unwrap();
2828 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2829 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2830 fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
2831 assert_eq!(count_pack_files(&pack_dir), 1);
2832 }
2833
2834 fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
2835 let dir = TempDir::new().unwrap();
2836 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2837 let config_path = repo.git_dir().join("config");
2838 let mut config = fs::read_to_string(&config_path).unwrap();
2839 config.push_str(snippet);
2840 fs::write(&config_path, config).unwrap();
2841 repo.reload_config().expect("reload config");
2842 (dir, repo)
2843 }
2844
2845 fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
2846 let dir = repo.git_dir().join("objects").join(fanout);
2847 fs::create_dir_all(&dir).unwrap();
2848 for name in names {
2849 fs::write(dir.join(name), b"").unwrap();
2850 }
2851 }
2852
2853 const NAME_A: &str = "00000000000000000000000000000000000001";
2854 const NAME_B: &str = "00000000000000000000000000000000000002";
2855 const NAME_C: &str = "00000000000000000000000000000000000003";
2856
2857 #[test]
2858 fn auto_gc_needed_samples_one_fanout_directory() {
2859 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
2861 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2862 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2863 fake_loose(&repo, "18", &[NAME_C]);
2864 fake_loose(
2865 &repo,
2866 "17",
2867 &["tmp_obj_123", "0000000000000000000000000000000000000z"],
2868 );
2869 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2870 fake_loose(&repo, "17", &[NAME_C]);
2871 assert_eq!(
2872 auto_gc_needed(&repo).unwrap(),
2873 Some(AutoGcReason::TooManyLoose {
2874 estimate: 3 * 256,
2875 limit: 300
2876 })
2877 );
2878 }
2879
2880 #[test]
2881 fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
2882 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
2883 fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
2884 let pack_dir = repo.git_dir().join("objects/pack");
2885 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2886 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2887 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2888 assert_eq!(
2889 gc_auto(&repo, &GcOptions::default()).unwrap(),
2890 AutoGcOutcome::NotNeeded
2891 );
2892 }
2893
2894 #[test]
2895 fn auto_gc_needed_counts_packs_without_keep_markers() {
2896 let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
2897 let pack_dir = repo.git_dir().join("objects/pack");
2898 for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
2899 fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
2900 }
2901 assert_eq!(
2902 auto_gc_needed(&repo).unwrap(),
2903 Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
2904 );
2905 fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
2906 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2907 }
2908
2909 fn config_snippet(text: &str) -> ConfigSet {
2910 let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
2911 let mut set = ConfigSet::new();
2912 set.merge(&file);
2913 set
2914 }
2915
2916 #[test]
2917 fn prune_expire_now_and_all_have_no_grace_window() {
2918 let now = 1_700_000_000;
2919 for spec in ["now", "all", "NOW"] {
2920 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2921 assert!(matches!(
2922 gc_loose_prune_policy(&cfg, now).unwrap(),
2923 GcLoosePrunePolicy::Enabled {
2924 keep_newer_than: None
2925 }
2926 ));
2927 }
2928 for spec in ["never", "false"] {
2929 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2930 assert!(matches!(
2931 gc_loose_prune_policy(&cfg, now).unwrap(),
2932 GcLoosePrunePolicy::Disabled
2933 ));
2934 }
2935 let cfg = ConfigSet::new();
2936 assert!(matches!(
2937 gc_loose_prune_policy(&cfg, now).unwrap(),
2938 GcLoosePrunePolicy::Enabled {
2939 keep_newer_than: Some(_)
2940 }
2941 ));
2942 }
2943
2944 #[test]
2945 fn gc_lock_is_exclusive_and_released_on_drop() {
2946 let (_dir, repo) = repo_with_config("");
2947 let now = OffsetDateTime::now_utc().unix_timestamp();
2948 let pid_path = repo.git_dir().join("gc.pid");
2949 let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
2950 panic!("first acquire must succeed");
2951 };
2952 let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
2953 assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
2954 assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
2955 match GcLock::try_acquire(&repo, now).unwrap() {
2956 GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
2957 GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
2958 }
2959 assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
2960 assert!(matches!(
2961 gc(&repo, &GcOptions::default()),
2962 Err(Error::MaintenanceRunning { .. })
2963 ));
2964 assert!(!repo.git_dir().join("gc.pid.lock").exists());
2965 drop(lock);
2966 assert!(!pid_path.exists());
2967 assert_eq!(GcLock::current_holder(&repo, now), None);
2968 assert!(matches!(
2969 GcLock::try_acquire(&repo, now).unwrap(),
2970 GcLockAttempt::Acquired(_)
2971 ));
2972 }
2973
2974 #[test]
2975 fn gc_lock_takes_over_stale_entries() {
2976 let (_dir, repo) = repo_with_config("");
2977 let now = OffsetDateTime::now_utc().unix_timestamp();
2978 let pid_path = repo.git_dir().join("gc.pid");
2979 let host = local_host_name();
2980
2981 fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
2983 assert!(matches!(
2984 GcLock::try_acquire(&repo, now).unwrap(),
2985 GcLockAttempt::Acquired(_)
2986 ));
2987
2988 fs::write(&pid_path, "4242 some-other-host").unwrap();
2990 assert!(matches!(
2991 GcLock::try_acquire(&repo, now).unwrap(),
2992 GcLockAttempt::Held(GcLockHolder {
2993 pid: Some(4242),
2994 ..
2995 })
2996 ));
2997
2998 let later = now + time::Duration::hours(13).whole_seconds();
3000 assert!(matches!(
3001 GcLock::try_acquire(&repo, later).unwrap(),
3002 GcLockAttempt::Acquired(_)
3003 ));
3004 }
3005
3006 #[test]
3007 fn gc_auto_reports_held_lock_without_running() {
3008 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3009 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3010 fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
3011 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3012 assert!(
3013 matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
3014 "{outcome:?}"
3015 );
3016 assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
3017 }
3018
3019 #[cfg(unix)]
3020 #[test]
3021 fn gc_auto_honors_failing_pre_auto_gc_hook() {
3022 use std::os::unix::fs::PermissionsExt as _;
3023 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3024 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3025 let hook = repo.git_dir().join("hooks/pre-auto-gc");
3026 fs::create_dir_all(hook.parent().unwrap()).unwrap();
3027 fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
3028 fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
3029 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3030 assert!(
3031 matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
3032 "{outcome:?}"
3033 );
3034 assert!(!repo.git_dir().join("gc.pid").exists());
3035 }
3036
3037 fn git_cmd(dir: &Path, args: &[&str]) {
3038 let status = std::process::Command::new("git")
3039 .current_dir(dir)
3040 .args(args)
3041 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3042 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3043 .status()
3044 .expect("git");
3045 assert!(status.success(), "git {args:?}");
3046 }
3047
3048 fn assert_fsck_clean(repo: &Repository) {
3049 let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
3050 let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
3051 assert!(
3052 report.ok(&repo.config().expect("config"), &opts),
3053 "{:?}",
3054 report.issues
3055 );
3056 }
3057
3058 #[test]
3059 fn fsck_clean_repo_ok() {
3060 let dir = TempDir::new().unwrap();
3061 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3062 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3063 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3064 let ident = "Test <t@example.com> 1 +0000".to_string();
3065 create_commit(
3066 &repo,
3067 &CommitRequest {
3068 message: "init".into(),
3069 author: Some(ident.parse().expect("signature")),
3070 committer: Some(ident.parse().expect("signature")),
3071 allow_empty: false,
3072 sign_override: None,
3073 amend: false,
3074 assume_worktree_synced: false,
3075 skip_pre_commit_tree_match: false,
3076 staged_change_count: None,
3077 ..Default::default()
3078 },
3079 &mut NullProgress,
3080 &NeverCancel,
3081 )
3082 .expect("commit");
3083 assert_fsck_clean(&repo);
3084 }
3085
3086 #[test]
3087 fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
3088 let dir = TempDir::new().unwrap();
3089 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3090 let wt = repo.work_tree().unwrap();
3091 std::fs::write(wt.join("base"), b"v1").unwrap();
3092 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3093 let ident = "Test <t@example.com> 1 +0000".to_string();
3094 create_commit(
3095 &repo,
3096 &CommitRequest {
3097 message: "base".into(),
3098 author: Some(ident.parse().expect("signature")),
3099 committer: Some(ident.parse().expect("signature")),
3100 allow_empty: false,
3101 sign_override: None,
3102 amend: false,
3103 assume_worktree_synced: false,
3104 skip_pre_commit_tree_match: false,
3105 staged_change_count: None,
3106 ..Default::default()
3107 },
3108 &mut NullProgress,
3109 &NeverCancel,
3110 )
3111 .expect("commit");
3112 repack_all(
3113 &repo,
3114 &RepackOptions {
3115 keep_unreachable: false,
3116 ..RepackOptions::default()
3117 },
3118 )
3119 .expect("repack base");
3120 let pack_dir = repo.git_dir().join("objects/pack");
3121 assert_eq!(count_pack_files(&pack_dir), 1);
3122
3123 std::fs::write(wt.join("child"), b"v2").unwrap();
3124 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
3125 create_commit(
3126 &repo,
3127 &CommitRequest {
3128 message: "child".into(),
3129 author: Some(ident.parse().expect("signature")),
3130 committer: Some(ident.parse().expect("signature")),
3131 allow_empty: false,
3132 sign_override: None,
3133 amend: false,
3134 assume_worktree_synced: false,
3135 skip_pre_commit_tree_match: false,
3136 staged_change_count: None,
3137 ..Default::default()
3138 },
3139 &mut NullProgress,
3140 &NeverCancel,
3141 )
3142 .expect("child commit");
3143 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3144
3145 let packs_before = count_pack_files(&pack_dir);
3146 repack_all(
3147 &repo,
3148 &RepackOptions {
3149 incremental: true,
3150 write_bitmap: true,
3151 ..RepackOptions::default()
3152 },
3153 )
3154 .expect("incremental repack with bitmap must not fail after installing a pack");
3155
3156 assert!(
3157 count_pack_files(&pack_dir) > packs_before,
3158 "incremental repack should add a pack"
3159 );
3160 let midx = pack_dir.join("multi-pack-index");
3161 assert!(
3162 midx.is_file(),
3163 "expected multi-pack-index after bitmap repack"
3164 );
3165 let midx_bytes = fs::read(&midx).expect("read midx");
3166 assert_eq!(
3167 midx_bytes.get(4),
3168 Some(&1),
3169 "MIDX on-disk version must be v1 for system git compatibility"
3170 );
3171 let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
3172 .unwrap()
3173 .filter_map(|e| e.ok())
3174 .map(|e| e.path())
3175 .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
3176 .collect();
3177 assert!(
3178 !bitmaps.is_empty(),
3179 "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
3180 );
3181
3182 assert_fsck_clean(&repo);
3183 }
3184
3185 #[test]
3186 fn incremental_repack_adds_pack_without_removing_base() {
3187 let dir = TempDir::new().unwrap();
3188 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3189 let wt = repo.work_tree().unwrap();
3190 std::fs::write(wt.join("base"), b"v1").unwrap();
3191 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3192 let ident = "Test <t@example.com> 1 +0000".to_string();
3193 create_commit(
3194 &repo,
3195 &CommitRequest {
3196 message: "base".into(),
3197 author: Some(ident.parse().expect("signature")),
3198 committer: Some(ident.parse().expect("signature")),
3199 allow_empty: false,
3200 sign_override: None,
3201 amend: false,
3202 assume_worktree_synced: false,
3203 skip_pre_commit_tree_match: false,
3204 staged_change_count: None,
3205 ..Default::default()
3206 },
3207 &mut NullProgress,
3208 &NeverCancel,
3209 )
3210 .expect("commit");
3211 repack_all(
3212 &repo,
3213 &RepackOptions {
3214 keep_unreachable: false,
3215 ..RepackOptions::default()
3216 },
3217 )
3218 .expect("repack");
3219 let pack_dir = repo.git_dir().join("objects/pack");
3220 let packs_before = count_pack_files(&pack_dir);
3221 assert_eq!(packs_before, 1);
3222 for i in 0..5 {
3223 create_commit(
3224 &repo,
3225 &CommitRequest {
3226 message: format!("empty-{i}"),
3227 author: Some(ident.parse().expect("signature")),
3228 committer: Some(ident.parse().expect("signature")),
3229 allow_empty: true,
3230 sign_override: None,
3231 amend: false,
3232 assume_worktree_synced: false,
3233 skip_pre_commit_tree_match: false,
3234 staged_change_count: None,
3235 ..Default::default()
3236 },
3237 &mut NullProgress,
3238 &NeverCancel,
3239 )
3240 .expect("empty");
3241 }
3242 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3243 repack_all(
3244 &repo,
3245 &RepackOptions {
3246 incremental: true,
3247 write_bitmap: false,
3248 ..RepackOptions::default()
3249 },
3250 )
3251 .expect("incremental repack");
3252 assert!(
3253 count_pack_files(&pack_dir) >= 2,
3254 "incremental repack should add a pack, not replace the base"
3255 );
3256 }
3257
3258 #[test]
3259 fn gc_full_run_records_per_phase_timings() {
3260 let dir = TempDir::new().unwrap();
3261 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3262 let wt = repo.work_tree().unwrap();
3263 for i in 0..5 {
3264 std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3265 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3266 let ident = "Test <t@example.com> 1 +0000".to_string();
3267 create_commit(
3268 &repo,
3269 &CommitRequest {
3270 message: format!("c{i}"),
3271 author: Some(ident.parse().expect("signature")),
3272 committer: Some(ident.parse().expect("signature")),
3273 allow_empty: false,
3274 sign_override: None,
3275 amend: false,
3276 assume_worktree_synced: false,
3277 skip_pre_commit_tree_match: false,
3278 staged_change_count: None,
3279 ..Default::default()
3280 },
3281 &mut NullProgress,
3282 &NeverCancel,
3283 )
3284 .expect("commit");
3285 }
3286 let stats = gc(
3287 &repo,
3288 &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
3289 )
3290 .expect("gc");
3291 let t = stats.phase_timings;
3292 assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
3293 assert!(
3294 t.repack_ms > 0,
3295 "repack phase must be timed separately from full gc"
3296 );
3297 assert!(stats.pack_refs.is_some());
3298 assert!(stats.commit_graph.is_some());
3299 assert_eq!(
3300 t.prune_ms, stats.repack.unreachable_prune_ms,
3301 "prune_ms must track unreachable loose prune inside repack, not repack_ms"
3302 );
3303 assert!(
3306 t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
3307 "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
3308 );
3309 assert!(stats.prune_ran, "full gc should run unreachable prune");
3310 }
3311
3312 #[cfg_attr(
3313 not(feature = "full-tests"),
3314 ignore = "starts system git or is heavy; run with --features full-tests"
3315 )]
3316 #[test]
3317 fn full_repack_keeps_migrated_pack_before_refs_updated() {
3318 use crate::receive_quarantine::ReceiveQuarantine;
3319
3320 let tmp = tempfile::tempdir().expect("tempdir");
3321 let remote = tmp.path().join("remote.git");
3322 let wt = tmp.path().join("wt");
3323 std::fs::create_dir_all(&wt).unwrap();
3324 std::fs::create_dir_all(&remote).unwrap();
3325 let git = |dir: &Path, args: &[&str]| {
3326 assert!(std::process::Command::new("git")
3327 .current_dir(dir)
3328 .args(args)
3329 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3330 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3331 .status()
3332 .expect("git")
3333 .success());
3334 };
3335 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3336 git(&wt, &["init", "-q", "-b", "main"]);
3337 std::fs::write(wt.join("payload.txt"), b"payload\n").unwrap();
3338 git(&wt, &["add", "payload.txt"]);
3339 git(&wt, &["commit", "-qm", "base"]);
3340 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3341 git(&remote, &["repack", "-a", "-d"]);
3342 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3343 .expect("open remote");
3344 let pack_dir = repo.git_dir().join("objects/pack");
3345 let base_pack = super::local_pack_paths(&pack_dir)
3346 .into_iter()
3347 .next()
3348 .expect("base pack");
3349 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3350 let q_root = PathBuf::from(
3351 q.hook_env()
3352 .into_iter()
3353 .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3354 .map(|(_, v)| v)
3355 .expect("quarantine path"),
3356 );
3357 let q_pack_dir = q_root.join("pack");
3358 fs::create_dir_all(&q_pack_dir).unwrap();
3359 for ext in ["pack", "idx", "rev"] {
3360 let src = base_pack.with_extension(ext);
3361 if src.is_file() {
3362 fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3363 }
3364 }
3365 let guards = q.migrate().expect("migrate");
3366 assert!(
3367 super::local_pack_paths(&pack_dir).len() >= 2,
3368 "migrated push pack must be visible before gc inventories packs"
3369 );
3370 repack_all(
3371 &repo,
3372 &RepackOptions {
3373 auto_skip: false,
3374 write_bitmap: false,
3375 ..RepackOptions::default()
3376 },
3377 )
3378 .expect("repack");
3379 assert!(
3380 pack_dir.join("pack-incoming.pack").is_file(),
3381 "incoming pack must survive full repack while .keep guards are held"
3382 );
3383 drop(guards);
3384 }
3385
3386 #[test]
3387 fn full_repack_drops_unprotected_migrated_pack_before_refs_updated() {
3388 use crate::receive_quarantine::ReceiveQuarantine;
3389
3390 let tmp = tempfile::tempdir().expect("tempdir");
3391 let remote = tmp.path().join("remote.git");
3392 let wt = tmp.path().join("wt");
3393 std::fs::create_dir_all(&wt).unwrap();
3394 std::fs::create_dir_all(&remote).unwrap();
3395 let git = |dir: &Path, args: &[&str]| {
3396 assert!(std::process::Command::new("git")
3397 .current_dir(dir)
3398 .args(args)
3399 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3400 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3401 .status()
3402 .expect("git")
3403 .success());
3404 };
3405 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3406 git(&wt, &["init", "-q", "-b", "main"]);
3407 git(&wt, &["commit", "--allow-empty", "-qm", "base"]);
3408 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3409 git(&remote, &["repack", "-a", "-d"]);
3410 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3411 .expect("open");
3412 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3413 let orphan = q
3414 .odb()
3415 .write(crate::objects::ObjectKind::Blob, b"orphan-bytes")
3416 .expect("write in quarantine");
3417 drop(q.migrate().expect("migrate"));
3418 repack_all(
3419 &repo,
3420 &RepackOptions {
3421 auto_skip: false,
3422 write_bitmap: false,
3423 keep_unreachable: false,
3424 ..RepackOptions::default()
3425 },
3426 )
3427 .expect("repack");
3428 assert!(
3429 !repo.odb().exists(&orphan),
3430 "without .keep protection gc may drop an unreachable migrated loose object"
3431 );
3432 }
3433
3434 #[cfg_attr(
3435 not(feature = "full-tests"),
3436 ignore = "starts system git or is heavy; run with --features full-tests"
3437 )]
3438 #[test]
3439 fn repack_retains_inventory_kept_pack_after_ref_publish_and_guard_drop() {
3440 use crate::receive_quarantine::ReceiveQuarantine;
3441 use std::process::Command;
3442 use std::sync::{Arc, Mutex};
3443
3444 let tmp = tempfile::tempdir().expect("tempdir");
3445 let remote = tmp.path().join("remote.git");
3446 let wt = tmp.path().join("wt");
3447 std::fs::create_dir_all(&wt).unwrap();
3448 std::fs::create_dir_all(&remote).unwrap();
3449 let git = |dir: &Path, args: &[&str]| {
3450 assert!(Command::new("git")
3451 .current_dir(dir)
3452 .args(args)
3453 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3454 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3455 .status()
3456 .expect("git")
3457 .success());
3458 };
3459 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3460 git(&wt, &["init", "-q", "-b", "main"]);
3461 std::fs::write(wt.join("payload.txt"), b"v1\n").unwrap();
3462 git(&wt, &["add", "payload.txt"]);
3463 git(&wt, &["commit", "-qm", "base"]);
3464 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3465 git(&remote, &["repack", "-a", "-d"]);
3466 std::fs::write(wt.join("payload.txt"), b"v2\n").unwrap();
3467 git(&wt, &["add", "payload.txt"]);
3468 git(&wt, &["commit", "-qm", "incoming"]);
3469 git(&wt, &["repack", "-a", "-d"]);
3470 let incoming_hex = String::from_utf8(
3471 Command::new("git")
3472 .current_dir(&wt)
3473 .args(["rev-parse", "HEAD"])
3474 .output()
3475 .expect("rev-parse")
3476 .stdout,
3477 )
3478 .expect("utf8")
3479 .trim()
3480 .to_owned();
3481 let incoming = ObjectId::from_hex(&incoming_hex).expect("incoming tip");
3482
3483 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3484 .expect("open remote");
3485 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3486 let q_root = PathBuf::from(
3487 q.hook_env()
3488 .into_iter()
3489 .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3490 .map(|(_, v)| v)
3491 .expect("quarantine path"),
3492 );
3493 let q_pack_dir = q_root.join("pack");
3494 fs::create_dir_all(&q_pack_dir).unwrap();
3495 let wt_pack_dir = wt.join(".git/objects/pack");
3496 let wt_pack = fs::read_dir(&wt_pack_dir)
3497 .expect("wt pack dir")
3498 .filter_map(|e| e.ok())
3499 .map(|e| e.path())
3500 .find(|p| p.extension().is_some_and(|ext| ext == "pack"))
3501 .expect("wt repack pack");
3502 for ext in ["pack", "idx", "rev"] {
3503 let src = wt_pack.with_extension(ext);
3504 if src.is_file() {
3505 fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3506 }
3507 }
3508 assert!(
3509 q_pack_dir.join("pack-incoming.pack").is_file(),
3510 "incoming pack sidecar"
3511 );
3512
3513 let pack_dir = repo.git_dir().join("objects/pack");
3514 let incoming_keep = pack_dir.join("pack-incoming.keep");
3515 let guards_slot = Arc::new(Mutex::new(None));
3516 let guards = q.migrate().expect("migrate");
3517 *guards_slot.lock().expect("lock") = Some(guards);
3518 assert!(incoming_keep.is_file());
3519
3520 let remote_for_hook = remote.clone();
3521 let incoming_for_hook = incoming_hex.clone();
3522 let incoming_keep_for_hook = incoming_keep.clone();
3523 let guards_for_hook = Arc::clone(&guards_slot);
3524 super::gc_test_hooks::on_next_gc(
3525 super::gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll,
3526 move || {
3527 assert!(Command::new("git")
3528 .current_dir(&remote_for_hook)
3529 .args(["update-ref", "refs/heads/main", &incoming_for_hook,])
3530 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3531 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3532 .status()
3533 .expect("update-ref")
3534 .success());
3535 drop(guards_for_hook.lock().expect("lock").take());
3536 assert!(
3537 !incoming_keep_for_hook.exists(),
3538 "receive path drops live keep markers after publication"
3539 );
3540 },
3541 );
3542
3543 repack_all(
3544 &repo,
3545 &RepackOptions {
3546 auto_skip: false,
3547 write_bitmap: false,
3548 ..RepackOptions::default()
3549 },
3550 )
3551 .expect("repack");
3552
3553 assert!(
3554 repo.odb().exists(&incoming),
3555 "inventory-time keep must protect the pack after live markers are dropped"
3556 );
3557 assert!(Command::new("git")
3558 .current_dir(&remote)
3559 .args(["fsck", "--strict"])
3560 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3561 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3562 .status()
3563 .expect("fsck")
3564 .success());
3565 }
3566}