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