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> {
1469 let odb = repo.odb();
1470 odb.require_files_primary("prune")?;
1471 let cfg = repo.config()?;
1472 let now = gc_now_unix(0);
1473 let keep_newer_than = match opts.older_than {
1474 Some(t) => Some(t),
1475 None => match gc_loose_prune_policy(&cfg, now)? {
1476 GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1477 GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1478 },
1479 };
1480 let roots = collect_maintenance_object_roots(repo)?;
1481 let shallow = load_shallow_boundaries(repo.git_dir());
1482 let mut stats = prune_loose_unreachable_with(
1483 odb,
1484 &LazyReachable::new(odb, &roots, &shallow),
1485 keep_newer_than,
1486 opts.dry_run,
1487 )?;
1488 let objects_dir = odb
1489 .files_objects_dir()
1490 .ok_or(Error::UnsupportedObjectStore {
1491 operation: "prune temporary objects",
1492 })?;
1493 let temp =
1494 prune_stale_temporary_objects_with_cutoff(objects_dir, keep_newer_than, opts.dry_run)?;
1495 stats.pruned += temp.pruned;
1496 stats.kept += temp.kept;
1497 Ok(stats)
1498}
1499
1500pub fn prune_stale_temporary_objects(
1513 repo: &Repository,
1514 keep_newer_than: Option<SystemTime>,
1515 dry_run: bool,
1516) -> Result<PruneStats> {
1517 repo.odb()
1518 .require_files_primary("prune temporary objects")?;
1519 let objects_dir = repo
1520 .odb()
1521 .files_objects_dir()
1522 .ok_or(Error::UnsupportedObjectStore {
1523 operation: "prune temporary objects",
1524 })?;
1525 prune_stale_temporary_objects_with_cutoff(objects_dir, keep_newer_than, dry_run)
1526}
1527
1528fn prune_stale_temporary_objects_with_cutoff(
1529 objects_dir: &Path,
1530 keep_newer_than: Option<SystemTime>,
1531 dry_run: bool,
1532) -> Result<PruneStats> {
1533 let mut stats = PruneStats::default();
1534 let mut consider = |path: &Path| -> Result<()> {
1535 let meta = match fs::symlink_metadata(path) {
1536 Ok(m) => m,
1537 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1538 Err(e) => return Err(Error::Io(e)),
1539 };
1540 if temporary_object_expired(&meta, keep_newer_than) {
1541 if dry_run {
1542 stats.pruned += 1;
1543 } else if meta.is_dir() {
1544 fs::remove_dir_all(path).map_err(Error::Io)?;
1545 stats.pruned += 1;
1546 } else {
1547 fs::remove_file(path).map_err(Error::Io)?;
1548 stats.pruned += 1;
1549 }
1550 } else {
1551 stats.kept += 1;
1552 }
1553 Ok(())
1554 };
1555
1556 if let Ok(entries) = fs::read_dir(objects_dir) {
1557 for entry in entries.flatten() {
1558 let name = entry.file_name();
1559 let name = name.to_string_lossy();
1560 if name.starts_with("tmp_") {
1561 consider(&entry.path())?;
1562 } else if name.len() == 2 && name.as_bytes().iter().all(|b| b.is_ascii_hexdigit()) {
1563 if let Ok(loose) = fs::read_dir(entry.path()) {
1564 for loose_entry in loose.flatten() {
1565 let loose_name = loose_entry.file_name();
1566 if loose_name.to_string_lossy().starts_with("tmp_obj_") {
1567 consider(&loose_entry.path())?;
1568 }
1569 }
1570 }
1571 }
1572 }
1573 }
1574
1575 let pack_dir = objects_dir.join("pack");
1576 if let Ok(entries) = fs::read_dir(&pack_dir) {
1577 for entry in entries.flatten() {
1578 let name = entry.file_name();
1579 let name = name.to_string_lossy();
1580 if name.starts_with("tmp_") {
1581 consider(&entry.path())?;
1582 continue;
1583 }
1584 if !is_grit_pack_install_stage_name(&name) {
1585 continue;
1586 }
1587 let meta = match fs::symlink_metadata(entry.path()) {
1588 Ok(m) => m,
1589 Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
1590 Err(e) => return Err(Error::Io(e)),
1591 };
1592 if meta.is_dir() {
1593 consider(&entry.path())?;
1594 }
1595 }
1596 }
1597
1598 let incoming = objects_dir.join("incoming");
1599 if let Ok(entries) = fs::read_dir(&incoming) {
1600 for entry in entries.flatten() {
1601 let name = entry.file_name();
1602 let name = name.to_string_lossy();
1603 if name.starts_with("pack-receive-") && name.ends_with(".tmp") {
1604 consider(&entry.path())?;
1605 }
1606 }
1607 }
1608 Ok(stats)
1609}
1610
1611fn is_grit_pack_install_stage_name(name: &str) -> bool {
1613 name.starts_with(".pack-") && name.contains("-install-")
1614}
1615
1616fn temporary_object_expired(meta: &fs::Metadata, keep_newer_than: Option<SystemTime>) -> bool {
1617 match keep_newer_than {
1618 None => true,
1619 Some(cutoff) => meta
1620 .modified()
1621 .ok()
1622 .is_none_or(|modified| modified <= cutoff),
1623 }
1624}
1625
1626fn duration_ms(d: std::time::Duration) -> u64 {
1627 d.as_millis() as u64
1628}
1629
1630pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1643 repo.odb().require_files_primary("gc")?;
1644 let now = gc_now_unix(opts.now_unix);
1645 let _lock = if opts.repack.dry_run {
1646 None
1647 } else {
1648 match GcLock::try_acquire(repo, now)? {
1649 GcLockAttempt::Acquired(lock) => Some(lock),
1650 GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1651 }
1652 };
1653 gc_locked(repo, opts, now)
1654}
1655
1656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1658pub enum AutoGcReason {
1659 TooManyLoose {
1662 estimate: usize,
1664 limit: usize,
1666 },
1667 TooManyPacks {
1670 count: usize,
1672 limit: usize,
1674 },
1675}
1676
1677const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1679
1680pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1695 let cfg = repo.config()?;
1696 let Some(threshold) = cfg.gc_auto_threshold() else {
1697 return Ok(None);
1698 };
1699 let Some(objects_dir) = repo.odb().files_objects_dir() else {
1700 return Ok(None);
1701 };
1702 if let Some(limit) = cfg.gc_auto_pack_limit() {
1703 let count = count_pack_files(&objects_dir.join("pack"));
1704 if count > limit {
1705 return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1706 }
1707 }
1708 let name_len = repo.odb().hash_algo().hex_len() - 2;
1709 let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1710 if sampled > threshold.div_ceil(256) {
1711 return Ok(Some(AutoGcReason::TooManyLoose {
1712 estimate: sampled.saturating_mul(256),
1713 limit: threshold,
1714 }));
1715 }
1716 Ok(None)
1717}
1718
1719fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1721 let entries = match fs::read_dir(dir) {
1722 Ok(entries) => entries,
1723 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1724 Err(e) => return Err(Error::Io(e)),
1725 };
1726 Ok(entries
1727 .filter_map(|e| e.ok())
1728 .filter(|e| {
1729 let name = e.file_name();
1730 let name = name.as_encoded_bytes();
1731 name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1732 })
1733 .count())
1734}
1735
1736#[derive(Debug, Clone, PartialEq, Eq)]
1738#[non_exhaustive]
1739pub enum AutoGcOutcome {
1740 NotNeeded,
1742 AlreadyRunning(GcLockHolder),
1744 DeclinedByHook {
1746 status: i32,
1748 output: crate::hooks::HookOutput,
1750 },
1751 Ran {
1753 reason: AutoGcReason,
1755 stats: Box<GcStats>,
1757 },
1758}
1759
1760pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1780 let Some(reason) = auto_gc_needed(repo)? else {
1781 return Ok(AutoGcOutcome::NotNeeded);
1782 };
1783 let now = gc_now_unix(opts.now_unix);
1784 let dry_run = opts.repack.dry_run;
1785 let _lock = if dry_run {
1786 None
1787 } else {
1788 match GcLock::try_acquire(repo, now)? {
1789 GcLockAttempt::Acquired(lock) => Some(lock),
1790 GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1791 }
1792 };
1793 if !dry_run {
1794 if let crate::hooks::HookResult::Failed { status, output } =
1795 crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1796 {
1797 return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1798 }
1799 }
1800 let mut run_opts = opts.clone();
1801 if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1802 run_opts.repack.incremental = true;
1803 run_opts.repack.write_bitmap = false;
1804 }
1805 let stats = gc_locked(repo, &run_opts, now)?;
1806 Ok(AutoGcOutcome::Ran {
1807 reason,
1808 stats: Box::new(stats),
1809 })
1810}
1811
1812#[derive(Debug, Clone, PartialEq, Eq)]
1814pub struct GcLockHolder {
1815 pub pid: Option<u64>,
1817 pub host: Option<String>,
1819}
1820
1821#[derive(Debug)]
1823#[must_use]
1824pub enum GcLockAttempt {
1825 Acquired(GcLock),
1827 Held(GcLockHolder),
1829}
1830
1831const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1833
1834#[derive(Debug)]
1840pub struct GcLock {
1841 path: PathBuf,
1842}
1843
1844impl GcLock {
1845 pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1853 let path = repo.common_git_dir().join("gc.pid");
1854 let mut lock = match crate::lockfile::LockFile::acquire(
1855 &path.with_extension("pid.lock"),
1856 crate::lockfile::LockWait::Immediately,
1857 ) {
1858 Ok(lock) => lock,
1859 Err(crate::lockfile::LockError::Held { .. }) => {
1860 return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1861 GcLockHolder {
1862 pid: None,
1863 host: None,
1864 },
1865 )));
1866 }
1867 Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1868 };
1869 let host = local_host_name();
1870 if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1871 lock.rollback();
1872 return Ok(GcLockAttempt::Held(holder));
1873 }
1874 use std::io::Write as _;
1875 write!(lock, "{} {host}", std::process::id())?;
1876 lock.commit_to(&path).map_err(|e| match e {
1877 crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1878 held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1879 })?;
1880 Ok(GcLockAttempt::Acquired(GcLock { path }))
1881 }
1882
1883 #[must_use]
1889 pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1890 let path = repo.common_git_dir().join("gc.pid");
1891 live_gc_pid_holder(&path, &local_host_name(), now_unix)
1892 }
1893}
1894
1895impl Drop for GcLock {
1896 fn drop(&mut self) {
1897 let _ = fs::remove_file(&self.path);
1898 }
1899}
1900
1901fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1902 let raw = fs::read_to_string(path).ok()?;
1903 let mut fields = raw.split_whitespace();
1904 let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1905 let host = fields.next().map(str::to_owned);
1906 Some(GcLockHolder { pid, host })
1907}
1908
1909fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1911 let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1912 let mtime = OffsetDateTime::from(mtime);
1913 let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1914 if now - mtime > GC_PID_STALE_AFTER {
1915 return None;
1916 }
1917 let holder = read_gc_pid(path)?;
1918 let pid = holder.pid?;
1919 let same_host = holder.host.as_deref() == Some(host);
1920 if same_host && !crate::lockfile::is_process_running(pid) {
1921 return None;
1922 }
1923 Some(holder)
1924}
1925
1926fn local_host_name() -> String {
1929 #[cfg(unix)]
1930 {
1931 if let Ok(uts) = nix::sys::utsname::uname() {
1932 if let Some(name) = uts.nodename().to_str() {
1933 return name.to_owned();
1934 }
1935 }
1936 }
1937 #[cfg(windows)]
1938 {
1939 if let Some(name) = windows_dns_host_name() {
1940 return name;
1941 }
1942 }
1943 "localhost".to_owned()
1944}
1945
1946#[cfg(windows)]
1948fn windows_dns_host_name() -> Option<String> {
1949 use windows_sys::Win32::System::SystemInformation::{
1950 ComputerNameDnsHostname, GetComputerNameExW,
1951 };
1952 let mut len: u32 = 0;
1953 unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1955 if len == 0 {
1956 return None;
1957 }
1958 let mut buf = vec![0u16; len as usize];
1959 let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1961 (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1962}
1963
1964fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
1965 let mut stats = GcStats::default();
1966 let dry_run = opts.repack.dry_run;
1967 let _ = crate::porcelain::commit::reap_stale_next_index_files(repo, dry_run)?;
1968 let cfg = repo.config()?;
1969 let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1970 let drop_plan = if opts.drop_large_blobs {
1972 Some(drop_blobs::plan_drop_for_gc(&cfg, opts.now_unix)?)
1973 } else {
1974 None
1975 };
1976 if opts.pack_refs && gc_should_pack_refs(repo, &cfg)? && !dry_run {
1977 let start = Instant::now();
1978 stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1979 stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1980 }
1981
1982 if opts.expire_reflog {
1983 let start = Instant::now();
1984 stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
1985 stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
1986 }
1987
1988 let mut repack_opts = opts.repack.clone();
1989 apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
1990 if repack_opts.now.is_none() {
1991 repack_opts.now =
1992 Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
1993 }
1994 if !repack_opts.write_bitmap && !repack_opts.incremental {
1997 repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg)?;
1998 }
1999 let repack_start = Instant::now();
2000 stats.repack = repack_all(repo, &repack_opts)?;
2001 let repack_total_ms = duration_ms(repack_start.elapsed());
2002 stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
2003 stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
2004
2005 if let Some(plan) = drop_plan {
2007 let start = Instant::now();
2008 stats.drop_large_blobs = Some(drop_blobs::run_drop_for_gc(
2009 repo,
2010 &cfg,
2011 plan,
2012 dry_run,
2013 opts.repack.threads,
2014 )?);
2015 stats.phase_timings.drop_large_blobs_ms = duration_ms(start.elapsed());
2016 }
2017
2018 stats.prune_ran = stats.repack.unreachable_prune_ran;
2021 stats.prune = PruneStats {
2022 pruned: stats.repack.unreachable_loose_pruned,
2023 kept: stats.repack.unreachable_loose_kept,
2024 };
2025
2026 if let GcLoosePrunePolicy::Enabled { keep_newer_than } = &prune_policy {
2027 let temp = prune_stale_temporary_objects_with_cutoff(
2028 repo.odb()
2029 .files_objects_dir()
2030 .ok_or(Error::UnsupportedObjectStore { operation: "gc" })?,
2031 *keep_newer_than,
2032 dry_run,
2033 )?;
2034 stats.prune.pruned += temp.pruned;
2035 stats.prune.kept += temp.kept;
2036 }
2037
2038 if dry_run {
2039 return Ok(stats);
2040 }
2041
2042 if opts.write_commit_graph && gc_should_write_commit_graph(&cfg)? {
2043 let start = Instant::now();
2044 stats.commit_graph = Some(write_commit_graph(
2045 repo,
2046 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
2047 )?);
2048 stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
2049 }
2050 Ok(stats)
2051}
2052
2053enum GcLoosePrunePolicy {
2055 Disabled,
2057 Enabled {
2059 keep_newer_than: Option<SystemTime>,
2061 },
2062}
2063
2064fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
2065 let spec = config
2066 .get("gc.pruneExpire")
2067 .map(|s| s.trim().to_owned())
2068 .filter(|s| !s.is_empty())
2069 .unwrap_or_else(|| "2.weeks.ago".to_owned());
2070 if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
2071 return Ok(GcLoosePrunePolicy::Disabled);
2072 }
2073 if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
2075 return Ok(GcLoosePrunePolicy::Enabled {
2076 keep_newer_than: None,
2077 });
2078 }
2079 let mut err = 0;
2080 let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
2081 if err != 0 {
2082 return Err(Error::Config(ConfigError::Other(format!(
2083 "bad config value '{spec}' for 'gc.pruneExpire'"
2084 ))));
2085 }
2086 let keep_newer_than = SystemTime::UNIX_EPOCH
2087 .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
2088 Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
2089}
2090
2091fn gc_now_unix(now_unix: i64) -> i64 {
2092 if now_unix != 0 {
2093 return now_unix;
2094 }
2095 time::OffsetDateTime::now_utc().unix_timestamp()
2096}
2097
2098fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
2099 let cfg = repo.config()?;
2100 let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
2101 let params = ReflogExpireParams {
2102 stale_fix: false,
2103 dry_run,
2104 verbose: false,
2105 };
2106 let mut total = 0usize;
2107 let cache = ReflogExpiryCache::default();
2108 for refname in reflog_refs_on_store(repo.refs())? {
2109 let result = expire_reflog_git_with_cache(
2110 repo,
2111 &refname,
2112 ¶ms,
2113 None,
2114 None,
2115 &gc_cfg.patterns,
2116 gc_cfg.global_total,
2117 gc_cfg.global_unreachable,
2118 now,
2119 &cache,
2120 )?;
2121 total += result.pruned;
2122 }
2123 Ok(total)
2124}
2125
2126fn count_loose_objects(odb: &Odb) -> Result<usize> {
2127 Ok(odb.enumerate_local_loose_objects()?.len())
2128}
2129
2130#[allow(clippy::too_many_arguments)]
2133fn build_repack_pack(
2134 odb: &Odb,
2135 pack_dir: &Path,
2136 roots: &[ObjectId],
2137 shallow: &HashSet<ObjectId>,
2138 keep_unreachable: bool,
2139 opts: &PackBuildOptions,
2140 threads: Option<usize>,
2141 cfg: Option<&ConfigSet>,
2142 promised: &HashSet<ObjectId>,
2143) -> Result<SerializedDeltaPack> {
2144 let selection = repack_object_list(
2145 odb,
2146 roots,
2147 shallow,
2148 keep_unreachable,
2149 opts.use_bitmaps,
2150 promised,
2151 )?;
2152 write_staged_repack_pack(
2153 odb,
2154 pack_dir,
2155 &selection.send,
2156 &selection.name_hashes,
2157 opts,
2158 threads,
2159 cfg,
2160 )
2161}
2162
2163pub(crate) fn write_staged_repack_pack(
2176 odb: &Odb,
2177 pack_dir: &Path,
2178 objects: &[ObjectId],
2179 name_hashes: &HashMap<ObjectId, u32>,
2180 opts: &PackBuildOptions,
2181 threads: Option<usize>,
2182 cfg: Option<&ConfigSet>,
2183) -> Result<SerializedDeltaPack> {
2184 if objects.is_empty() {
2185 return Ok(SerializedDeltaPack {
2186 bytes: Vec::new(),
2187 index_rows: Vec::new(),
2188 staging_path: None,
2189 });
2190 }
2191 odb.clear_delta_base_cache();
2192 let max_depth = if opts.delta { opts.max_depth } else { 0 };
2193 let settings = RepackPackSettings {
2194 window: opts.window,
2195 max_depth,
2196 reuse_deltas: opts.reuse_deltas,
2197 reuse_objects: opts.reuse_objects,
2198 use_ofs_delta: opts.use_ofs_delta,
2199 window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
2200 search_threads: pack_delta_search_parallel_options(odb, threads)?
2201 .threads
2202 .get(),
2203 write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
2204 };
2205 let git_dir = odb
2206 .config_git_dir()
2207 .map(Path::to_path_buf)
2208 .unwrap_or_else(|| {
2209 pack_dir
2210 .parent()
2211 .and_then(|objects| objects.parent())
2212 .map(Path::to_path_buf)
2213 .unwrap_or_else(|| pack_dir.to_path_buf())
2214 });
2215 crate::git_dir::io_error::io_with_git_path(
2216 &git_dir,
2217 pack_dir,
2218 crate::git_dir::GitDirPathOperation::GcPack,
2219 fs::create_dir_all(pack_dir),
2220 )?;
2221 let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
2222 let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
2223 match written {
2224 Ok(index_rows) => Ok(SerializedDeltaPack {
2225 bytes: Vec::new(),
2226 index_rows,
2227 staging_path: Some(staging),
2228 }),
2229 Err(err) => {
2230 let _ = fs::remove_file(&staging);
2231 Err(err)
2232 }
2233 }
2234}
2235
2236fn repack_object_list(
2240 odb: &Odb,
2241 roots: &[ObjectId],
2242 shallow: &HashSet<ObjectId>,
2243 keep_unreachable: bool,
2244 use_bitmaps: bool,
2245 promised: &HashSet<ObjectId>,
2246) -> Result<PackObjectSelection> {
2247 let mut roots: Vec<_> = roots
2250 .iter()
2251 .copied()
2252 .filter(|oid| !promised.contains(oid))
2253 .collect();
2254 roots.sort_unstable();
2255 let alternates = AlternateSources::load(odb)?;
2256 let use_bitmaps = use_bitmaps && promised.is_empty() && !alternates.in_use();
2257 let mut selection = enumerate_pack_objects_naming(
2258 odb,
2259 &roots,
2260 &[],
2261 &PackEnumerateOptions {
2262 filter: None,
2263 shallow_grafts: shallow,
2264 have_shallow_grafts: Some(shallow),
2265 use_bitmaps,
2266 exclude_objects: Some(promised),
2267 peer_odb: None,
2268 have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
2269 },
2270 NameTrees::Yes,
2271 true,
2272 &crate::progress::CancelSignal::never(),
2273 )?;
2274 if keep_unreachable {
2275 append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
2276 }
2277 Ok(selection)
2278}
2279
2280fn append_repack_unreachable_retention(
2282 odb: &Odb,
2283 send: &mut Vec<ObjectId>,
2284 promised: &HashSet<ObjectId>,
2285) -> Result<()> {
2286 let alternates = AlternateSources::load(odb)?;
2287 let mut in_send: OidHashSet = send.iter().copied().collect();
2288 odb.primary()?
2289 .packed_objects()
2290 .for_each_removable_pack_object(&mut |oid| {
2291 if !promised.contains(oid) && !alternates.contains(oid) && in_send.insert(*oid) {
2292 send.push(*oid);
2293 }
2294 std::ops::ControlFlow::Continue(())
2295 })?;
2296 for (oid, _) in odb.enumerate_local_loose_objects()? {
2297 if !promised.contains(&oid) && !alternates.contains(&oid) && in_send.insert(oid) {
2298 send.push(oid);
2299 }
2300 }
2301 Ok(())
2302}
2303
2304fn pack_repack_inventory(pack_dir: &Path) -> crate::odb::RepackPackInventorySnapshot {
2305 let pack_paths = local_pack_paths(pack_dir);
2306 let kept_at_inventory = pack_paths
2307 .iter()
2308 .filter(|pack_path| pack_has_keep_marker(pack_dir, pack_path))
2309 .cloned()
2310 .collect();
2311 crate::odb::RepackPackInventorySnapshot {
2312 pack_paths,
2313 kept_at_inventory,
2314 }
2315}
2316
2317fn local_pack_paths(pack_dir: &Path) -> HashSet<PathBuf> {
2319 let Ok(entries) = fs::read_dir(pack_dir) else {
2320 return HashSet::new();
2321 };
2322 entries
2323 .filter_map(|e| e.ok())
2324 .map(|e| pack_dir.join(e.file_name()))
2325 .filter(|p| p.extension().is_some_and(|e| e == "pack"))
2326 .collect()
2327}
2328
2329fn count_pack_files(pack_dir: &Path) -> usize {
2331 let Ok(entries) = fs::read_dir(pack_dir) else {
2332 return 0;
2333 };
2334 let mut packs = HashSet::new();
2335 let mut kept = HashSet::new();
2336 for entry in entries.filter_map(|e| e.ok()) {
2337 let path = PathBuf::from(entry.file_name());
2338 let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
2339 continue;
2340 };
2341 if ext == "pack" {
2342 packs.insert(stem.to_owned());
2343 } else if ext == "keep" {
2344 kept.insert(stem.to_owned());
2345 }
2346 }
2347 packs.difference(&kept).count()
2348}
2349
2350fn packs_holding_available_promises(
2354 odb: &Odb,
2355 promised: &HashSet<ObjectId>,
2356) -> Result<HashSet<PathBuf>> {
2357 if promised.is_empty() {
2358 return Ok(HashSet::new());
2359 }
2360 let indexes = odb.local_pack_indexes()?;
2361 let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
2362 let mut retained = HashSet::new();
2363 for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
2364 for entry in idx.iter() {
2365 let oid = ObjectId::from_bytes(entry.oid())?;
2366 if promised.contains(&oid)
2367 && !promisor_indexes
2368 .iter()
2369 .any(|idx| idx.find_position(&oid).is_some())
2370 {
2371 retained.insert(idx.pack_path.clone());
2372 break;
2373 }
2374 }
2375 }
2376 Ok(retained)
2377}
2378
2379fn remove_superseded_pack_files_retaining(
2382 pack_dir: &Path,
2383 keep_pack: &Path,
2384 retained_packs: &HashSet<PathBuf>,
2385 inventory: &crate::odb::RepackPackInventorySnapshot,
2386) -> Result<usize> {
2387 let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
2388 let keep_stem = keep_pack
2389 .file_stem()
2390 .and_then(|s| s.to_str())
2391 .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
2392 if !pack_dir.is_dir() {
2393 return Ok(0);
2394 }
2395 let mut removed = 0usize;
2396 for entry in crate::git_dir::io_error::io_with_git_path(
2397 &git_dir,
2398 pack_dir,
2399 crate::git_dir::GitDirPathOperation::GcPack,
2400 fs::read_dir(pack_dir),
2401 )? {
2402 let entry = entry.map_err(|source| {
2403 crate::git_dir::io_error::map_git_dir_io(
2404 &git_dir,
2405 pack_dir,
2406 crate::git_dir::GitDirPathOperation::GcPack,
2407 source,
2408 )
2409 })?;
2410 let name = entry.file_name();
2411 let name_str = name.to_string_lossy();
2412 if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2413 continue;
2414 }
2415 if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2416 continue;
2417 }
2418 let path = entry.path();
2419 if path.is_file() {
2420 let pack_path = path.with_extension("pack");
2421 if inventory.kept_at_inventory.contains(&pack_path)
2422 || path.with_extension("promisor").is_file()
2423 || retained_packs.contains(&pack_path)
2424 || !inventory.pack_paths.contains(&pack_path)
2425 {
2426 continue;
2427 }
2428 crate::git_dir::io_error::io_with_git_path(
2429 &git_dir,
2430 &path,
2431 crate::git_dir::GitDirPathOperation::GcPack,
2432 fs::remove_file(&path),
2433 )?;
2434 removed += 1;
2435 }
2436 }
2437 Ok(removed)
2438}
2439
2440#[derive(Debug, Clone, Default, PartialEq, Eq)]
2442pub(crate) struct PromisorRewrite {
2443 pub(crate) pack_path: Option<PathBuf>,
2446 pub(crate) objects: usize,
2448 pub(crate) dropped: usize,
2450 pub(crate) removed_pack_files: usize,
2452}
2453
2454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2456pub(crate) enum PromisorRewriteWhen {
2457 Dropping,
2459 Always,
2461}
2462
2463pub(crate) fn rewrite_local_objects_without(
2502 repo: &Repository,
2503 omit: &OidHashSet,
2504 opts: &RepackOptions,
2505 when: PromisorRewriteWhen,
2506) -> Result<PromisorRewrite> {
2507 let odb = repo.odb();
2508 odb.require_files_primary("promisor rewrite")?;
2509 if omit.is_empty() && when == PromisorRewriteWhen::Dropping {
2510 return Ok(PromisorRewrite::default());
2511 }
2512 let objects_dir = odb.objects_dir().to_path_buf();
2513 let pack_dir = objects_dir.join("pack");
2514 odb.reprepare_local_packs();
2515 let snapshot: Vec<_> = odb
2516 .local_pack_indexes()?
2517 .into_iter()
2518 .filter(|idx| !pack_has_keep_marker(&pack_dir, &idx.pack_path))
2519 .collect();
2520
2521 let mut send = Vec::new();
2522 let mut seen: OidHashSet = OidHashSet::default();
2523 let mut dropped: OidHashSet = OidHashSet::default();
2524 let mut take = |oid: ObjectId| {
2525 if omit.contains(&oid) {
2526 dropped.insert(oid);
2527 } else if seen.insert(oid) {
2528 send.push(oid);
2529 }
2530 };
2531 for idx in &snapshot {
2532 for entry in idx.iter() {
2533 take(ObjectId::from_bytes(entry.oid())?);
2534 }
2535 }
2536 let loose = odb.enumerate_local_loose_objects()?;
2537 for (oid, _) in &loose {
2538 take(*oid);
2539 }
2540 let folded = loose.is_empty() && snapshot.len() <= 1 && snapshot.iter().all(|i| i.is_promisor);
2541 let nothing_to_do = match when {
2542 PromisorRewriteWhen::Dropping => dropped.is_empty(),
2543 PromisorRewriteWhen::Always => dropped.is_empty() && folded,
2544 };
2545 if nothing_to_do {
2546 return Ok(PromisorRewrite::default());
2547 }
2548
2549 let cfg = opts
2550 .merged_config
2551 .clone()
2552 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
2553 let pack_opts = opts
2554 .pack_build
2555 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
2556 let built = write_staged_repack_pack(
2557 odb,
2558 &pack_dir,
2559 &send,
2560 &HashMap::new(),
2561 &pack_opts,
2562 opts.threads,
2563 cfg.as_ref(),
2564 )?;
2565 clear_pack_midx_state(&pack_dir)?;
2566 let mut rewrite = PromisorRewrite {
2567 dropped: dropped.len(),
2568 ..PromisorRewrite::default()
2569 };
2570 if let Some(staging) = built.staging_path.as_ref() {
2571 let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
2572 let ingested = install_pack_path(
2573 staging,
2574 odb,
2575 &IngestPackOptions {
2576 threads: opts.threads,
2577 merged_config: cfg.clone(),
2578 environment: opts.environment.clone(),
2579 skip_post_index_verify: trusted_rows.is_some(),
2580 trusted_index_rows: trusted_rows,
2581 mark_promisor_pack: true,
2582 ..IngestPackOptions::default()
2583 },
2584 )?;
2585 rewrite.objects = ingested.object_ids.len();
2586 if let Some(pack_path) = &ingested.pack_path {
2587 if cfg
2588 .as_ref()
2589 .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
2590 {
2591 write_pack_rev_sidecar_for_index_path(&pack_path.with_extension("idx"))?;
2592 }
2593 crate::index_pack::sync_installed_pack(pack_path)?;
2594 }
2595 rewrite.pack_path = ingested.pack_path;
2596 }
2597
2598 odb.invalidate_packs();
2600 let new_stem = rewrite
2601 .pack_path
2602 .as_deref()
2603 .and_then(Path::file_stem)
2604 .map(|s| s.to_os_string());
2605 let stems: HashSet<std::ffi::OsString> = snapshot
2606 .iter()
2607 .filter_map(|idx| idx.pack_path.file_stem().map(|s| s.to_os_string()))
2608 .filter(|stem| Some(stem) != new_stem.as_ref())
2609 .collect();
2610 drop(snapshot);
2611 rewrite.removed_pack_files = remove_pack_files_with_stems(&pack_dir, &stems)?;
2612 for oid in &dropped {
2613 odb.remove_local_loose(oid)?;
2614 }
2615 prune_packed_objects(&objects_dir, PrunePackedOptions::default())?;
2616 odb.invalidate_packs();
2617 Ok(rewrite)
2618}
2619
2620fn remove_pack_files_with_stems(
2624 pack_dir: &Path,
2625 stems: &HashSet<std::ffi::OsString>,
2626) -> Result<usize> {
2627 if stems.is_empty() {
2628 return Ok(0);
2629 }
2630 let mut removed = 0usize;
2631 for entry in fs::read_dir(pack_dir)?.filter_map(|e| e.ok()) {
2632 let path = entry.path();
2633 let Some(stem) = path.file_stem().filter(|stem| stems.contains(*stem)) else {
2634 continue;
2635 };
2636 if !path.is_file() || pack_dir.join(stem).with_extension("keep").is_file() {
2637 continue;
2638 }
2639 fs::remove_file(&path)?;
2640 removed += 1;
2641 }
2642 Ok(removed)
2643}
2644
2645fn pack_has_keep_marker(pack_dir: &Path, pack_path: &Path) -> bool {
2646 let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
2647 return false;
2648 };
2649 pack_dir.join(format!("{stem}.keep")).is_file()
2650}
2651
2652pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2661 let (path, refs_packed, refs_pruned) =
2662 crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2663 Ok(PackRefsReport {
2664 path,
2665 refs_packed,
2666 refs_pruned,
2667 })
2668}
2669
2670impl Repository {
2671 pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2677 write_commit_graph(self, opts)
2678 }
2679
2680 pub fn build_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphBuild> {
2686 build_commit_graph(self, opts)
2687 }
2688
2689 pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2695 write_midx(self, opts)
2696 }
2697}
2698
2699pub mod fsck {
2701 pub use crate::fsck_msg_policy::{
2702 FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2703 };
2704 pub use crate::fsck_standalone::*;
2705}
2706
2707pub mod repository_fsck {
2709 pub use crate::fsck::*;
2710}
2711
2712#[cfg(test)]
2713pub(crate) mod gc_test_hooks {
2714 use std::cell::RefCell;
2715
2716 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
2718 pub enum GcPausePoint {
2719 AfterInventoryBeforeRoots,
2721 AfterRootsBeforeDropAll,
2723 }
2724
2725 type GcHook = (GcPausePoint, Box<dyn FnOnce() + Send>);
2726
2727 thread_local! {
2728 static GC_HOOK: RefCell<Option<GcHook>> = const { RefCell::new(None) };
2729 }
2730
2731 pub fn on_next_gc(point: GcPausePoint, hook: impl FnOnce() + Send + 'static) {
2733 GC_HOOK.with(|slot| *slot.borrow_mut() = Some((point, Box::new(hook))));
2734 }
2735
2736 pub(crate) fn run(point: GcPausePoint) {
2737 let hook = GC_HOOK.with(|slot| {
2738 let mut slot = slot.borrow_mut();
2739 match slot.as_ref() {
2740 Some((at, _)) if *at == point => slot.take().map(|(_, hook)| hook),
2741 _ => None,
2742 }
2743 });
2744 if let Some(hook) = hook {
2745 hook();
2746 }
2747 }
2748}
2749
2750#[cfg(test)]
2751mod tests {
2752 #![allow(clippy::unwrap_used, clippy::expect_used)]
2753
2754 use super::*;
2755 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2756 use crate::porcelain::add::stage_for_commit;
2757 use crate::porcelain::commit::{create_commit, CommitRequest};
2758 use crate::progress::{NeverCancel, NullProgress};
2759 use crate::repo::InitOptions;
2760 use filetime::FileTime;
2761 use std::time::SystemTime;
2762 use tempfile::TempDir;
2763
2764 fn pack_into(repo: &Repository, oids: &[ObjectId], keep: bool) -> PathBuf {
2767 let pack = crate::pack_objects::build_pack_from_send_list(
2768 repo.odb(),
2769 oids,
2770 &HashSet::new(),
2771 &PackBuildOptions::default(),
2772 &HashMap::new(),
2773 )
2774 .unwrap();
2775 install_pack_bytes(
2776 pack,
2777 repo.odb(),
2778 &IngestPackOptions {
2779 keep_message: keep.then(|| "pinned".to_owned()),
2780 ..IngestPackOptions::default()
2781 },
2782 )
2783 .unwrap()
2784 .pack_path
2785 .unwrap()
2786 }
2787
2788 #[test]
2789 fn promisor_rewrite_drops_only_the_omitted_objects() {
2790 let dir = TempDir::new().unwrap();
2791 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2792 let odb = repo.odb();
2793 let big = odb.write(ObjectKind::Blob, b"big blob").unwrap();
2794 let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2795 let kept = odb.write(ObjectKind::Blob, b"kept").unwrap();
2796 let unreachable = odb.write(ObjectKind::Blob, b"unreachable loose").unwrap();
2797 let mut body = b"100644 big\0".to_vec();
2798 body.extend_from_slice(big.as_bytes());
2799 let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2800 let old_pack = pack_into(&repo, &[packed, big, tree], false);
2801 let kept_pack = pack_into(&repo, &[kept], true);
2802 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2803 odb.write_loose_materialize(ObjectKind::Blob, b"big blob")
2805 .unwrap();
2806
2807 let omit: OidHashSet = [big, kept].into_iter().collect();
2808 let rewrite = rewrite_local_objects_without(
2809 &repo,
2810 &omit,
2811 &RepackOptions::default(),
2812 PromisorRewriteWhen::Dropping,
2813 )
2814 .unwrap();
2815 assert_eq!(rewrite.dropped, 1, "the kept copy is not ours to drop");
2816 assert_eq!(rewrite.objects, 3, "tree + packed + unreachable");
2817 let new_pack = rewrite.pack_path.expect("new pack");
2818 assert!(new_pack.with_extension("promisor").is_file());
2819 repo.odb().reprepare_local_packs();
2822 let indexes = repo.odb().local_pack_indexes().unwrap();
2823 let new_idx = indexes
2824 .iter()
2825 .find(|i| i.pack_path == new_pack)
2826 .expect("listed");
2827 for oid in [tree, packed, unreachable] {
2828 assert!(new_idx.find_position(&oid).is_some(), "{oid}");
2829 }
2830 assert!(!old_pack.exists());
2831 assert!(kept_pack.is_file() && kept_pack.with_extension("keep").is_file());
2832 assert!(rewrite.removed_pack_files >= 2, "old .pack and .idx");
2833
2834 let repo =
2835 Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2836 let odb = repo.odb();
2837 assert!(!odb.exists_stored(&big));
2838 assert!(odb.local_loose_file_size(&big).is_none());
2839 for oid in [packed, kept, unreachable, tree] {
2840 assert!(odb.exists_stored(&oid), "{oid} kept");
2841 }
2842 assert!(odb.enumerate_local_loose_objects().unwrap().is_empty());
2843 }
2844
2845 #[test]
2846 fn promisor_rewrite_without_anything_to_drop_changes_nothing() {
2847 let dir = TempDir::new().unwrap();
2848 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2849 let blob = repo.odb().write(ObjectKind::Blob, b"x").unwrap();
2850 let absent = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
2851 for omit in [OidHashSet::default(), [absent].into_iter().collect()] {
2852 let rewrite = rewrite_local_objects_without(
2853 &repo,
2854 &omit,
2855 &RepackOptions::default(),
2856 PromisorRewriteWhen::Dropping,
2857 )
2858 .unwrap();
2859 assert_eq!(rewrite, PromisorRewrite::default());
2860 }
2861 assert!(repo.odb().local_loose_file_size(&blob).is_some());
2862 }
2863
2864 #[test]
2865 fn promisor_rewrite_always_folds_until_one_promisor_pack_is_left() {
2866 let dir = TempDir::new().unwrap();
2867 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2868 let odb = repo.odb();
2869 let pushed = odb.write(ObjectKind::Blob, b"pushed").unwrap();
2870 let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2871 let mut body = b"100644 packed\0".to_vec();
2872 body.extend_from_slice(packed.as_bytes());
2873 let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2874 let tree_pack = pack_into(&repo, &[tree], false);
2877 fs::write(tree_pack.with_extension("promisor"), b"").unwrap();
2878 let blob_pack = pack_into(&repo, &[packed], false);
2879 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2880 odb.write_loose_materialize(ObjectKind::Blob, b"pushed")
2881 .unwrap();
2882 let fold = |repo: &Repository| {
2883 rewrite_local_objects_without(
2884 repo,
2885 &OidHashSet::default(),
2886 &RepackOptions::default(),
2887 PromisorRewriteWhen::Always,
2888 )
2889 .unwrap()
2890 };
2891 let first = fold(&repo);
2892 assert_eq!(first.dropped, 0);
2893 assert_eq!(first.objects, 3);
2894 let pack = first.pack_path.expect("folded pack");
2895 assert!(pack.with_extension("promisor").is_file());
2896 assert!(!tree_pack.exists() && !blob_pack.exists());
2897 let repo =
2898 Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2899 assert!(repo
2900 .odb()
2901 .enumerate_local_loose_objects()
2902 .unwrap()
2903 .is_empty());
2904 let indexes = repo.odb().local_pack_indexes().unwrap();
2905 assert_eq!(indexes.len(), 1);
2906 for oid in [pushed, packed, tree] {
2907 assert!(indexes[0].find_position(&oid).is_some(), "{oid}");
2908 }
2909 assert_eq!(fold(&repo), PromisorRewrite::default());
2911 assert!(pack.is_file());
2912 }
2913
2914 #[test]
2915 fn pack_removal_spares_unlisted_and_newly_kept_packs() {
2916 let dir = TempDir::new().unwrap();
2917 for name in [
2918 "pack-a.pack",
2919 "pack-a.idx",
2920 "pack-a.rev",
2921 "pack-b.pack",
2922 "pack-b.idx",
2923 "pack-c.pack",
2924 "pack-c.keep",
2925 ] {
2926 fs::write(dir.path().join(name), b"").unwrap();
2927 }
2928 let stems: HashSet<std::ffi::OsString> =
2929 ["pack-a", "pack-c"].into_iter().map(Into::into).collect();
2930 assert_eq!(remove_pack_files_with_stems(dir.path(), &stems).unwrap(), 3);
2931 let mut left: Vec<String> = fs::read_dir(dir.path())
2932 .unwrap()
2933 .flatten()
2934 .map(|e| e.file_name().to_string_lossy().into_owned())
2935 .collect();
2936 left.sort();
2937 assert_eq!(
2938 left,
2939 ["pack-b.idx", "pack-b.pack", "pack-c.keep", "pack-c.pack"]
2940 );
2941 }
2942
2943 #[cfg_attr(
2944 not(feature = "full-tests"),
2945 ignore = "starts system git or is heavy; run with --features full-tests"
2946 )]
2947 #[test]
2948 fn gc_auto_skip_single_pack_without_loose() {
2949 let dir = TempDir::new().unwrap();
2950 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2951 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2952 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2953 let ident = "Test <t@example.com> 1 +0000".to_string();
2954 create_commit(
2955 &repo,
2956 &CommitRequest {
2957 message: "init".into(),
2958 author: Some(ident.parse().expect("signature")),
2959 committer: Some(ident.parse().expect("signature")),
2960 allow_empty: false,
2961 sign_override: None,
2962 amend: false,
2963 assume_worktree_synced: false,
2964 skip_pre_commit_tree_match: false,
2965 staged_change_count: None,
2966 ..Default::default()
2967 },
2968 &mut NullProgress,
2969 &NeverCancel,
2970 )
2971 .expect("commit");
2972 git_cmd(dir.path(), &["repack", "-a", "-d"]);
2973 assert!(should_auto_skip_repack(0, 1, false, true, None));
2974 assert!(!should_auto_skip_repack(3, 1, false, true, None));
2975 assert!(!should_auto_skip_repack(0, 2, false, true, None));
2976 assert!(!should_auto_skip_repack(0, 2, false, false, None));
2977 }
2978
2979 #[test]
2980 fn count_pack_files_ignores_keep_markers() {
2981 let dir = TempDir::new().unwrap();
2982 let pack_dir = dir.path().join("pack");
2983 fs::create_dir_all(&pack_dir).unwrap();
2984 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2985 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2986 fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
2987 assert_eq!(count_pack_files(&pack_dir), 1);
2988 }
2989
2990 fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
2991 let dir = TempDir::new().unwrap();
2992 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2993 let config_path = repo.git_dir().join("config");
2994 let mut config = fs::read_to_string(&config_path).unwrap();
2995 config.push_str(snippet);
2996 fs::write(&config_path, config).unwrap();
2997 repo.reload_config().expect("reload config");
2998 (dir, repo)
2999 }
3000
3001 fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
3002 let dir = repo.git_dir().join("objects").join(fanout);
3003 fs::create_dir_all(&dir).unwrap();
3004 for name in names {
3005 fs::write(dir.join(name), b"").unwrap();
3006 }
3007 }
3008
3009 const NAME_A: &str = "00000000000000000000000000000000000001";
3010 const NAME_B: &str = "00000000000000000000000000000000000002";
3011 const NAME_C: &str = "00000000000000000000000000000000000003";
3012
3013 #[test]
3014 fn auto_gc_needed_samples_one_fanout_directory() {
3015 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
3017 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3018 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3019 fake_loose(&repo, "18", &[NAME_C]);
3020 fake_loose(
3021 &repo,
3022 "17",
3023 &["tmp_obj_123", "0000000000000000000000000000000000000z"],
3024 );
3025 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3026 fake_loose(&repo, "17", &[NAME_C]);
3027 assert_eq!(
3028 auto_gc_needed(&repo).unwrap(),
3029 Some(AutoGcReason::TooManyLoose {
3030 estimate: 3 * 256,
3031 limit: 300
3032 })
3033 );
3034 }
3035
3036 #[test]
3037 fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
3038 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
3039 fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
3040 let pack_dir = repo.git_dir().join("objects/pack");
3041 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
3042 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
3043 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3044 assert_eq!(
3045 gc_auto(&repo, &GcOptions::default()).unwrap(),
3046 AutoGcOutcome::NotNeeded
3047 );
3048 }
3049
3050 #[test]
3051 fn auto_gc_needed_counts_packs_without_keep_markers() {
3052 let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
3053 let pack_dir = repo.git_dir().join("objects/pack");
3054 for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
3055 fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
3056 }
3057 assert_eq!(
3058 auto_gc_needed(&repo).unwrap(),
3059 Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
3060 );
3061 fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
3062 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3063 }
3064
3065 fn config_snippet(text: &str) -> ConfigSet {
3066 let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
3067 let mut set = ConfigSet::new();
3068 set.merge(&file);
3069 set
3070 }
3071
3072 #[test]
3073 fn prune_expire_now_and_all_have_no_grace_window() {
3074 let now = 1_700_000_000;
3075 for spec in ["now", "all", "NOW"] {
3076 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3077 assert!(matches!(
3078 gc_loose_prune_policy(&cfg, now).unwrap(),
3079 GcLoosePrunePolicy::Enabled {
3080 keep_newer_than: None
3081 }
3082 ));
3083 }
3084 for spec in ["never", "false"] {
3085 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3086 assert!(matches!(
3087 gc_loose_prune_policy(&cfg, now).unwrap(),
3088 GcLoosePrunePolicy::Disabled
3089 ));
3090 }
3091 let cfg = ConfigSet::new();
3092 assert!(matches!(
3093 gc_loose_prune_policy(&cfg, now).unwrap(),
3094 GcLoosePrunePolicy::Enabled {
3095 keep_newer_than: Some(_)
3096 }
3097 ));
3098 }
3099
3100 #[test]
3101 fn gc_lock_is_exclusive_and_released_on_drop() {
3102 let (_dir, repo) = repo_with_config("");
3103 let now = OffsetDateTime::now_utc().unix_timestamp();
3104 let pid_path = repo.git_dir().join("gc.pid");
3105 let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
3106 panic!("first acquire must succeed");
3107 };
3108 let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
3109 assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
3110 assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
3111 match GcLock::try_acquire(&repo, now).unwrap() {
3112 GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
3113 GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
3114 }
3115 assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
3116 assert!(matches!(
3117 gc(&repo, &GcOptions::default()),
3118 Err(Error::MaintenanceRunning { .. })
3119 ));
3120 assert!(!repo.git_dir().join("gc.pid.lock").exists());
3121 drop(lock);
3122 assert!(!pid_path.exists());
3123 assert_eq!(GcLock::current_holder(&repo, now), None);
3124 assert!(matches!(
3125 GcLock::try_acquire(&repo, now).unwrap(),
3126 GcLockAttempt::Acquired(_)
3127 ));
3128 }
3129
3130 #[test]
3131 fn gc_lock_takes_over_stale_entries() {
3132 let (_dir, repo) = repo_with_config("");
3133 let now = OffsetDateTime::now_utc().unix_timestamp();
3134 let pid_path = repo.git_dir().join("gc.pid");
3135 let host = local_host_name();
3136
3137 fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
3139 assert!(matches!(
3140 GcLock::try_acquire(&repo, now).unwrap(),
3141 GcLockAttempt::Acquired(_)
3142 ));
3143
3144 fs::write(&pid_path, "4242 some-other-host").unwrap();
3146 assert!(matches!(
3147 GcLock::try_acquire(&repo, now).unwrap(),
3148 GcLockAttempt::Held(GcLockHolder {
3149 pid: Some(4242),
3150 ..
3151 })
3152 ));
3153
3154 let later = now + time::Duration::hours(13).whole_seconds();
3156 assert!(matches!(
3157 GcLock::try_acquire(&repo, later).unwrap(),
3158 GcLockAttempt::Acquired(_)
3159 ));
3160 }
3161
3162 #[test]
3163 fn gc_auto_reports_held_lock_without_running() {
3164 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3165 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3166 fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
3167 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3168 assert!(
3169 matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
3170 "{outcome:?}"
3171 );
3172 assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
3173 }
3174
3175 #[cfg(unix)]
3176 #[test]
3177 fn gc_auto_honors_failing_pre_auto_gc_hook() {
3178 use std::os::unix::fs::PermissionsExt as _;
3179 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3180 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3181 let hook = repo.git_dir().join("hooks/pre-auto-gc");
3182 fs::create_dir_all(hook.parent().unwrap()).unwrap();
3183 fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
3184 fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
3185 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3186 assert!(
3187 matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
3188 "{outcome:?}"
3189 );
3190 assert!(!repo.git_dir().join("gc.pid").exists());
3191 }
3192
3193 fn git_cmd(dir: &Path, args: &[&str]) {
3194 let status = std::process::Command::new("git")
3195 .current_dir(dir)
3196 .args(args)
3197 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3198 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3199 .status()
3200 .expect("git");
3201 assert!(status.success(), "git {args:?}");
3202 }
3203
3204 fn assert_fsck_clean(repo: &Repository) {
3205 let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
3206 let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
3207 assert!(
3208 report.ok(&repo.config().expect("config"), &opts),
3209 "{:?}",
3210 report.issues
3211 );
3212 }
3213
3214 #[test]
3215 fn fsck_clean_repo_ok() {
3216 let dir = TempDir::new().unwrap();
3217 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3218 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3219 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3220 let ident = "Test <t@example.com> 1 +0000".to_string();
3221 create_commit(
3222 &repo,
3223 &CommitRequest {
3224 message: "init".into(),
3225 author: Some(ident.parse().expect("signature")),
3226 committer: Some(ident.parse().expect("signature")),
3227 allow_empty: false,
3228 sign_override: None,
3229 amend: false,
3230 assume_worktree_synced: false,
3231 skip_pre_commit_tree_match: false,
3232 staged_change_count: None,
3233 ..Default::default()
3234 },
3235 &mut NullProgress,
3236 &NeverCancel,
3237 )
3238 .expect("commit");
3239 assert_fsck_clean(&repo);
3240 }
3241
3242 #[test]
3243 fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
3244 let dir = TempDir::new().unwrap();
3245 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3246 let wt = repo.work_tree().unwrap();
3247 std::fs::write(wt.join("base"), b"v1").unwrap();
3248 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3249 let ident = "Test <t@example.com> 1 +0000".to_string();
3250 create_commit(
3251 &repo,
3252 &CommitRequest {
3253 message: "base".into(),
3254 author: Some(ident.parse().expect("signature")),
3255 committer: Some(ident.parse().expect("signature")),
3256 allow_empty: false,
3257 sign_override: None,
3258 amend: false,
3259 assume_worktree_synced: false,
3260 skip_pre_commit_tree_match: false,
3261 staged_change_count: None,
3262 ..Default::default()
3263 },
3264 &mut NullProgress,
3265 &NeverCancel,
3266 )
3267 .expect("commit");
3268 repack_all(
3269 &repo,
3270 &RepackOptions {
3271 keep_unreachable: false,
3272 ..RepackOptions::default()
3273 },
3274 )
3275 .expect("repack base");
3276 let pack_dir = repo.git_dir().join("objects/pack");
3277 assert_eq!(count_pack_files(&pack_dir), 1);
3278
3279 std::fs::write(wt.join("child"), b"v2").unwrap();
3280 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
3281 create_commit(
3282 &repo,
3283 &CommitRequest {
3284 message: "child".into(),
3285 author: Some(ident.parse().expect("signature")),
3286 committer: Some(ident.parse().expect("signature")),
3287 allow_empty: false,
3288 sign_override: None,
3289 amend: false,
3290 assume_worktree_synced: false,
3291 skip_pre_commit_tree_match: false,
3292 staged_change_count: None,
3293 ..Default::default()
3294 },
3295 &mut NullProgress,
3296 &NeverCancel,
3297 )
3298 .expect("child commit");
3299 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3300
3301 let packs_before = count_pack_files(&pack_dir);
3302 repack_all(
3303 &repo,
3304 &RepackOptions {
3305 incremental: true,
3306 write_bitmap: true,
3307 ..RepackOptions::default()
3308 },
3309 )
3310 .expect("incremental repack with bitmap must not fail after installing a pack");
3311
3312 assert!(
3313 count_pack_files(&pack_dir) > packs_before,
3314 "incremental repack should add a pack"
3315 );
3316 let midx = pack_dir.join("multi-pack-index");
3317 assert!(
3318 midx.is_file(),
3319 "expected multi-pack-index after bitmap repack"
3320 );
3321 let midx_bytes = fs::read(&midx).expect("read midx");
3322 assert_eq!(
3323 midx_bytes.get(4),
3324 Some(&1),
3325 "MIDX on-disk version must be v1 for system git compatibility"
3326 );
3327 let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
3328 .unwrap()
3329 .filter_map(|e| e.ok())
3330 .map(|e| e.path())
3331 .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
3332 .collect();
3333 assert!(
3334 !bitmaps.is_empty(),
3335 "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
3336 );
3337
3338 assert_fsck_clean(&repo);
3339 }
3340
3341 #[test]
3342 fn incremental_repack_adds_pack_without_removing_base() {
3343 let dir = TempDir::new().unwrap();
3344 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3345 let wt = repo.work_tree().unwrap();
3346 std::fs::write(wt.join("base"), b"v1").unwrap();
3347 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3348 let ident = "Test <t@example.com> 1 +0000".to_string();
3349 create_commit(
3350 &repo,
3351 &CommitRequest {
3352 message: "base".into(),
3353 author: Some(ident.parse().expect("signature")),
3354 committer: Some(ident.parse().expect("signature")),
3355 allow_empty: false,
3356 sign_override: None,
3357 amend: false,
3358 assume_worktree_synced: false,
3359 skip_pre_commit_tree_match: false,
3360 staged_change_count: None,
3361 ..Default::default()
3362 },
3363 &mut NullProgress,
3364 &NeverCancel,
3365 )
3366 .expect("commit");
3367 repack_all(
3368 &repo,
3369 &RepackOptions {
3370 keep_unreachable: false,
3371 ..RepackOptions::default()
3372 },
3373 )
3374 .expect("repack");
3375 let pack_dir = repo.git_dir().join("objects/pack");
3376 let packs_before = count_pack_files(&pack_dir);
3377 assert_eq!(packs_before, 1);
3378 for i in 0..5 {
3379 create_commit(
3380 &repo,
3381 &CommitRequest {
3382 message: format!("empty-{i}"),
3383 author: Some(ident.parse().expect("signature")),
3384 committer: Some(ident.parse().expect("signature")),
3385 allow_empty: true,
3386 sign_override: None,
3387 amend: false,
3388 assume_worktree_synced: false,
3389 skip_pre_commit_tree_match: false,
3390 staged_change_count: None,
3391 ..Default::default()
3392 },
3393 &mut NullProgress,
3394 &NeverCancel,
3395 )
3396 .expect("empty");
3397 }
3398 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3399 repack_all(
3400 &repo,
3401 &RepackOptions {
3402 incremental: true,
3403 write_bitmap: false,
3404 ..RepackOptions::default()
3405 },
3406 )
3407 .expect("incremental repack");
3408 assert!(
3409 count_pack_files(&pack_dir) >= 2,
3410 "incremental repack should add a pack, not replace the base"
3411 );
3412 }
3413
3414 #[test]
3415 fn gc_full_run_records_per_phase_timings() {
3416 let dir = TempDir::new().unwrap();
3417 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3418 let wt = repo.work_tree().unwrap();
3419 for i in 0..5 {
3420 std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3421 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3422 let ident = "Test <t@example.com> 1 +0000".to_string();
3423 create_commit(
3424 &repo,
3425 &CommitRequest {
3426 message: format!("c{i}"),
3427 author: Some(ident.parse().expect("signature")),
3428 committer: Some(ident.parse().expect("signature")),
3429 allow_empty: false,
3430 sign_override: None,
3431 amend: false,
3432 assume_worktree_synced: false,
3433 skip_pre_commit_tree_match: false,
3434 staged_change_count: None,
3435 ..Default::default()
3436 },
3437 &mut NullProgress,
3438 &NeverCancel,
3439 )
3440 .expect("commit");
3441 }
3442 let stats = gc(
3443 &repo,
3444 &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
3445 )
3446 .expect("gc");
3447 let t = stats.phase_timings;
3448 assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
3449 assert!(
3450 t.repack_ms > 0,
3451 "repack phase must be timed separately from full gc"
3452 );
3453 assert!(stats.pack_refs.is_some());
3454 assert!(stats.commit_graph.is_some());
3455 assert_eq!(
3456 t.prune_ms, stats.repack.unreachable_prune_ms,
3457 "prune_ms must track unreachable loose prune inside repack, not repack_ms"
3458 );
3459 assert!(
3462 t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
3463 "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
3464 );
3465 assert!(stats.prune_ran, "full gc should run unreachable prune");
3466 }
3467
3468 #[cfg_attr(
3469 not(feature = "full-tests"),
3470 ignore = "starts system git or is heavy; run with --features full-tests"
3471 )]
3472 #[test]
3473 fn full_repack_keeps_migrated_pack_before_refs_updated() {
3474 use crate::receive_quarantine::ReceiveQuarantine;
3475
3476 let tmp = tempfile::tempdir().expect("tempdir");
3477 let remote = tmp.path().join("remote.git");
3478 let wt = tmp.path().join("wt");
3479 std::fs::create_dir_all(&wt).unwrap();
3480 std::fs::create_dir_all(&remote).unwrap();
3481 let git = |dir: &Path, args: &[&str]| {
3482 assert!(std::process::Command::new("git")
3483 .current_dir(dir)
3484 .args(args)
3485 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3486 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3487 .status()
3488 .expect("git")
3489 .success());
3490 };
3491 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3492 git(&wt, &["init", "-q", "-b", "main"]);
3493 std::fs::write(wt.join("payload.txt"), b"payload\n").unwrap();
3494 git(&wt, &["add", "payload.txt"]);
3495 git(&wt, &["commit", "-qm", "base"]);
3496 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3497 git(&remote, &["repack", "-a", "-d"]);
3498 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3499 .expect("open remote");
3500 let pack_dir = repo.git_dir().join("objects/pack");
3501 let base_pack = super::local_pack_paths(&pack_dir)
3502 .into_iter()
3503 .next()
3504 .expect("base pack");
3505 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3506 let q_root = PathBuf::from(
3507 q.hook_env()
3508 .into_iter()
3509 .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3510 .map(|(_, v)| v)
3511 .expect("quarantine path"),
3512 );
3513 let q_pack_dir = q_root.join("pack");
3514 fs::create_dir_all(&q_pack_dir).unwrap();
3515 for ext in ["pack", "idx", "rev"] {
3516 let src = base_pack.with_extension(ext);
3517 if src.is_file() {
3518 fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3519 }
3520 }
3521 let guards = q.migrate().expect("migrate");
3522 assert!(
3523 super::local_pack_paths(&pack_dir).len() >= 2,
3524 "migrated push pack must be visible before gc inventories packs"
3525 );
3526 repack_all(
3527 &repo,
3528 &RepackOptions {
3529 auto_skip: false,
3530 write_bitmap: false,
3531 ..RepackOptions::default()
3532 },
3533 )
3534 .expect("repack");
3535 assert!(
3536 pack_dir.join("pack-incoming.pack").is_file(),
3537 "incoming pack must survive full repack while .keep guards are held"
3538 );
3539 drop(guards);
3540 }
3541
3542 #[test]
3543 fn full_repack_drops_unprotected_migrated_pack_before_refs_updated() {
3544 use crate::receive_quarantine::ReceiveQuarantine;
3545
3546 let tmp = tempfile::tempdir().expect("tempdir");
3547 let remote = tmp.path().join("remote.git");
3548 let wt = tmp.path().join("wt");
3549 std::fs::create_dir_all(&wt).unwrap();
3550 std::fs::create_dir_all(&remote).unwrap();
3551 let git = |dir: &Path, args: &[&str]| {
3552 assert!(std::process::Command::new("git")
3553 .current_dir(dir)
3554 .args(args)
3555 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3556 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3557 .status()
3558 .expect("git")
3559 .success());
3560 };
3561 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3562 git(&wt, &["init", "-q", "-b", "main"]);
3563 git(&wt, &["commit", "--allow-empty", "-qm", "base"]);
3564 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3565 git(&remote, &["repack", "-a", "-d"]);
3566 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3567 .expect("open");
3568 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3569 let orphan = q
3570 .odb()
3571 .write(crate::objects::ObjectKind::Blob, b"orphan-bytes")
3572 .expect("write in quarantine");
3573 drop(q.migrate().expect("migrate"));
3574 repack_all(
3575 &repo,
3576 &RepackOptions {
3577 auto_skip: false,
3578 write_bitmap: false,
3579 keep_unreachable: false,
3580 ..RepackOptions::default()
3581 },
3582 )
3583 .expect("repack");
3584 assert!(
3585 !repo.odb().exists(&orphan),
3586 "without .keep protection gc may drop an unreachable migrated loose object"
3587 );
3588 }
3589
3590 #[cfg_attr(
3591 not(feature = "full-tests"),
3592 ignore = "starts system git or is heavy; run with --features full-tests"
3593 )]
3594 #[test]
3595 fn repack_retains_inventory_kept_pack_after_ref_publish_and_guard_drop() {
3596 use crate::receive_quarantine::ReceiveQuarantine;
3597 use std::process::Command;
3598 use std::sync::{Arc, Mutex};
3599
3600 let tmp = tempfile::tempdir().expect("tempdir");
3601 let remote = tmp.path().join("remote.git");
3602 let wt = tmp.path().join("wt");
3603 std::fs::create_dir_all(&wt).unwrap();
3604 std::fs::create_dir_all(&remote).unwrap();
3605 let git = |dir: &Path, args: &[&str]| {
3606 assert!(Command::new("git")
3607 .current_dir(dir)
3608 .args(args)
3609 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3610 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3611 .status()
3612 .expect("git")
3613 .success());
3614 };
3615 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3616 git(&wt, &["init", "-q", "-b", "main"]);
3617 std::fs::write(wt.join("payload.txt"), b"v1\n").unwrap();
3618 git(&wt, &["add", "payload.txt"]);
3619 git(&wt, &["commit", "-qm", "base"]);
3620 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3621 git(&remote, &["repack", "-a", "-d"]);
3622 std::fs::write(wt.join("payload.txt"), b"v2\n").unwrap();
3623 git(&wt, &["add", "payload.txt"]);
3624 git(&wt, &["commit", "-qm", "incoming"]);
3625 git(&wt, &["repack", "-a", "-d"]);
3626 let incoming_hex = String::from_utf8(
3627 Command::new("git")
3628 .current_dir(&wt)
3629 .args(["rev-parse", "HEAD"])
3630 .output()
3631 .expect("rev-parse")
3632 .stdout,
3633 )
3634 .expect("utf8")
3635 .trim()
3636 .to_owned();
3637 let incoming = ObjectId::from_hex(&incoming_hex).expect("incoming tip");
3638
3639 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3640 .expect("open remote");
3641 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3642 let q_root = PathBuf::from(
3643 q.hook_env()
3644 .into_iter()
3645 .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3646 .map(|(_, v)| v)
3647 .expect("quarantine path"),
3648 );
3649 let q_pack_dir = q_root.join("pack");
3650 fs::create_dir_all(&q_pack_dir).unwrap();
3651 let wt_pack_dir = wt.join(".git/objects/pack");
3652 let wt_pack = fs::read_dir(&wt_pack_dir)
3653 .expect("wt pack dir")
3654 .filter_map(|e| e.ok())
3655 .map(|e| e.path())
3656 .find(|p| p.extension().is_some_and(|ext| ext == "pack"))
3657 .expect("wt repack pack");
3658 for ext in ["pack", "idx", "rev"] {
3659 let src = wt_pack.with_extension(ext);
3660 if src.is_file() {
3661 fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3662 }
3663 }
3664 assert!(
3665 q_pack_dir.join("pack-incoming.pack").is_file(),
3666 "incoming pack sidecar"
3667 );
3668
3669 let pack_dir = repo.git_dir().join("objects/pack");
3670 let incoming_keep = pack_dir.join("pack-incoming.keep");
3671 let guards_slot = Arc::new(Mutex::new(None));
3672 let guards = q.migrate().expect("migrate");
3673 *guards_slot.lock().expect("lock") = Some(guards);
3674 assert!(incoming_keep.is_file());
3675
3676 let remote_for_hook = remote.clone();
3677 let incoming_for_hook = incoming_hex.clone();
3678 let incoming_keep_for_hook = incoming_keep.clone();
3679 let guards_for_hook = Arc::clone(&guards_slot);
3680 super::gc_test_hooks::on_next_gc(
3681 super::gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll,
3682 move || {
3683 assert!(Command::new("git")
3684 .current_dir(&remote_for_hook)
3685 .args(["update-ref", "refs/heads/main", &incoming_for_hook,])
3686 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3687 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3688 .status()
3689 .expect("update-ref")
3690 .success());
3691 drop(guards_for_hook.lock().expect("lock").take());
3692 assert!(
3693 !incoming_keep_for_hook.exists(),
3694 "receive path drops live keep markers after publication"
3695 );
3696 },
3697 );
3698
3699 repack_all(
3700 &repo,
3701 &RepackOptions {
3702 auto_skip: false,
3703 write_bitmap: false,
3704 ..RepackOptions::default()
3705 },
3706 )
3707 .expect("repack");
3708
3709 assert!(
3710 repo.odb().exists(&incoming),
3711 "inventory-time keep must protect the pack after live markers are dropped"
3712 );
3713 assert!(Command::new("git")
3714 .current_dir(&remote)
3715 .args(["fsck", "--strict"])
3716 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3717 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3718 .status()
3719 .expect("fsck")
3720 .success());
3721 }
3722
3723 #[test]
3724 fn pack_install_stage_name_matches_only_grit_staging_dirs() {
3725 assert!(is_grit_pack_install_stage_name(
3726 ".pack-deadbeef-install-1_0"
3727 ));
3728 assert!(!is_grit_pack_install_stage_name(".notes-install-backup"));
3729 assert!(!is_grit_pack_install_stage_name(".cache-install-state"));
3730 assert!(is_grit_pack_install_stage_name(
3731 ".pack-deadbeef-install-stale-file"
3732 ));
3733 }
3734
3735 #[test]
3736 fn gc_prunes_stale_tmp_pack_and_install_dirs() {
3737 let (_dir, repo) = repo_with_config("");
3738 let pack_dir = repo.git_dir().join("objects/pack");
3739 fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3740 fs::write(pack_dir.join("tmp_pack_fresh"), b"new").unwrap();
3741 fs::create_dir(pack_dir.join(".pack-deadbeef-install-9_0")).unwrap();
3742 fs::write(pack_dir.join(".notes-install-backup"), b"notes").unwrap();
3743 fs::create_dir(pack_dir.join(".cache-install-state")).unwrap();
3744 fs::write(
3745 pack_dir.join(".pack-deadbeef-install-stale-file"),
3746 b"not a stage dir",
3747 )
3748 .unwrap();
3749 let cutoff = SystemTime::now() - std::time::Duration::from_secs(21 * 86400);
3750 filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3751 filetime::set_file_mtime(
3752 pack_dir.join(".pack-deadbeef-install-9_0"),
3753 FileTime::from(cutoff),
3754 )
3755 .unwrap();
3756 for path in [
3757 pack_dir.join(".notes-install-backup"),
3758 pack_dir.join(".cache-install-state"),
3759 pack_dir.join(".pack-deadbeef-install-stale-file"),
3760 ] {
3761 filetime::set_file_mtime(&path, FileTime::from(cutoff)).unwrap();
3762 }
3763 gc(&repo, &GcOptions::default()).expect("gc");
3764 assert!(!pack_dir.join("tmp_pack_old").exists());
3765 assert!(!pack_dir.join(".pack-deadbeef-install-9_0").exists());
3766 assert!(pack_dir.join("tmp_pack_fresh").exists());
3767 assert!(pack_dir.join(".notes-install-backup").exists());
3768 assert!(pack_dir.join(".cache-install-state").is_dir());
3769 assert!(pack_dir.join(".pack-deadbeef-install-stale-file").is_file());
3770
3771 fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3772 filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3773 let stats = gc(
3774 &repo,
3775 &GcOptions {
3776 repack: RepackOptions {
3777 dry_run: true,
3778 ..RepackOptions::default()
3779 },
3780 ..GcOptions::default()
3781 },
3782 )
3783 .expect("dry-run gc");
3784 assert!(stats.prune.pruned >= 1);
3785 assert!(pack_dir.join("tmp_pack_old").exists());
3786 }
3787
3788 #[test]
3789 fn gc_reaps_stale_next_index_files_in_git_and_linked_worktree() {
3790 use crate::worktree::{add_linked_worktree, AddLinkedWorktreeOptions, WorktreeBranch};
3791
3792 let (dir, repo) = repo_with_config("");
3793 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3794 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3795 let ident = "Test <t@example.com> 1 +0000".to_string();
3796 create_commit(
3797 &repo,
3798 &CommitRequest {
3799 message: "init".into(),
3800 author: Some(ident.parse().expect("signature")),
3801 committer: Some(ident.parse().expect("signature")),
3802 allow_empty: false,
3803 sign_override: None,
3804 amend: false,
3805 assume_worktree_synced: false,
3806 skip_pre_commit_tree_match: false,
3807 staged_change_count: None,
3808 ..Default::default()
3809 },
3810 &mut NullProgress,
3811 &NeverCancel,
3812 )
3813 .expect("commit");
3814 let main_git = repo.git_dir().to_path_buf();
3815 let dead_pid = 99_999_997_u64;
3816 let live_pid = u64::from(std::process::id());
3817 fs::write(
3818 main_git.join(format!("next-index-{dead_pid}")),
3819 b"main orphan",
3820 )
3821 .unwrap();
3822 fs::write(
3823 main_git.join(format!("next-index-{live_pid}")),
3824 b"main live",
3825 )
3826 .unwrap();
3827
3828 let linked_path = dir.path().join("linked-wt");
3829 add_linked_worktree(
3830 &repo,
3831 &AddLinkedWorktreeOptions {
3832 path: linked_path.clone(),
3833 branch: Some(WorktreeBranch::Create("linked".into())),
3834 start_point: Some("HEAD".into()),
3835 force: false,
3836 lock: None,
3837 no_checkout: true,
3838 track: false,
3839 },
3840 )
3841 .expect("linked worktree");
3842 let linked_git = crate::worktree::list_worktrees(&repo)
3843 .unwrap()
3844 .into_iter()
3845 .find(|e| e.path == linked_path)
3846 .expect("linked entry")
3847 .admin_dir;
3848 fs::write(
3849 linked_git.join(format!("next-index-{dead_pid}")),
3850 b"linked orphan",
3851 )
3852 .unwrap();
3853
3854 gc(&repo, &GcOptions::default()).expect("gc");
3855 assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3856 assert!(main_git.join(format!("next-index-{live_pid}")).exists());
3857 assert!(!linked_git.join(format!("next-index-{dead_pid}")).exists());
3858
3859 fs::write(main_git.join(format!("next-index-{dead_pid}")), b"again").unwrap();
3860 gc(
3861 &repo,
3862 &GcOptions {
3863 repack: RepackOptions {
3864 dry_run: true,
3865 ..RepackOptions::default()
3866 },
3867 ..GcOptions::default()
3868 },
3869 )
3870 .expect("dry-run gc");
3871 assert!(main_git.join(format!("next-index-{dead_pid}")).exists());
3872 gc(&repo, &GcOptions::default()).expect("gc again");
3873 assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3874 }
3875}