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_infos_for_repository, remove_commit_graph_chain_dir,
28 remove_commit_graph_files, BloomWriteStats, CommitGraphCommitInfo,
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::{
38 install_pack_bytes_with_progress, install_pack_path, install_pack_path_with_progress,
39 IngestPackOptions,
40};
41use crate::midx::{
42 clear_pack_midx_state, midx_version_from_config, midx_write_version,
43 write_multi_pack_index_with_options, MidxBitmapWriteOutcome, WriteMultiPackIndexOptions,
44 WriteMultiPackIndexResult,
45};
46use crate::objects::{
47 oid_hash_map, tag_object_line_oid, CommitRef, FileMode, ObjectId, ObjectKind, OidHashMap,
48 OidHashSet, TreeRefIter,
49};
50use crate::odb::Odb;
51use crate::odb::UnreachablePackRetention;
52use crate::pack_bitmap::{PackBitmapWriteError, PackBitmapWriteOptions, PackBitmapWriter};
53use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
54use crate::pack_name_hash::pack_name_hash_child;
55use crate::pack_object_select::{
56 enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
57};
58use crate::pack_objects::{PackBuildOptions, SerializedDeltaPack};
59use crate::pack_rev::write_pack_rev_sidecar_for_index_path;
60use crate::progress::{poll_cancel, Cancel, NeverCancel, NullProgress};
61use crate::prune_packed::{prune_packed_objects, PrunePackedOptions};
62use crate::reflog::{
63 expire_reflog_git_with_cache, load_gc_reflog_expire_config, reflog_refs_on_store,
64 ReflogExpireParams, ReflogExpiryCache,
65};
66use crate::repack_pack::{write_repack_pack, RepackPackSettings};
67use crate::repo::Repository;
68use crate::shallow::load_shallow_boundaries;
69use time::OffsetDateTime;
70
71mod drop_blobs;
72
73pub use crate::offload::{
74 offload_blobs, LopReadiness, OffloadConfigChange, OffloadError, OffloadOptions, OffloadPack,
75 OffloadPackRole, OffloadRepo, OffloadReport, LOP_GIT_DIR_KEY, LOP_KEEP_MESSAGE,
76};
77pub use drop_blobs::{
78 drop_large_blobs_enabled, drop_promised_blobs, DropExpiry, DropPromisedBlobsOptions,
79 DropPromisedBlobsReport, DropPromisedBlobsSkip, DroppedBlob, KeptBlobCounts,
80 DEFAULT_DROP_EXPIRE, DEFAULT_DROP_MIN_SIZE, DROP_LARGE_BLOBS_TASK,
81};
82
83#[derive(Debug, Clone)]
85#[non_exhaustive]
86pub struct RepackOptions {
87 pub dry_run: bool,
89 pub write_commit_graph: bool,
91 pub write_bitmap: bool,
93 pub prune_older_than: Option<OffsetDateTime>,
95 pub now: Option<OffsetDateTime>,
97 pub keep_unreachable: bool,
99 pub threads: Option<usize>,
101 pub pack_build: Option<PackBuildOptions>,
108 pub merged_config: Option<ConfigSet>,
110 pub environment: Environment,
112 pub incremental: bool,
121 pub auto_skip: bool,
125 pub force_recompress: bool,
129}
130
131impl Default for RepackOptions {
132 fn default() -> Self {
133 Self {
134 dry_run: false,
135 write_commit_graph: false,
136 write_bitmap: false,
137 prune_older_than: None,
138 now: None,
139 keep_unreachable: true,
140 threads: None,
141 pack_build: None,
142 merged_config: None,
143 environment: Environment::empty(),
144 incremental: false,
145 auto_skip: false,
146 force_recompress: false,
147 }
148 }
149}
150
151impl RepackOptions {
152 #[must_use]
154 pub fn with_dry_run(dry_run: bool) -> Self {
155 Self {
156 dry_run,
157 ..Self::default()
158 }
159 }
160}
161
162struct RepackDetail {
163 report: RepackReport,
164 pack_path: Option<PathBuf>,
165 prune_packed_loose: usize,
166 unreachable_loose: PruneStats,
167 unreachable_prune_ms: u64,
168 unreachable_prune_ran: bool,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
173#[non_exhaustive]
174pub struct RepackReport {
175 pub packs_written: usize,
177 pub packs_removed: usize,
179 pub loose_removed: usize,
181 pub objects: usize,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct RepackStats {
189 pub loose_before: usize,
191 pub packs_before: usize,
193 pub objects_packed: Option<usize>,
196 pub pack_bytes: Option<u64>,
198 pub pack_path: Option<PathBuf>,
201 pub removed_pack_files: usize,
203 pub pruned_loose: usize,
205 pub unreachable_loose_pruned: usize,
207 pub unreachable_loose_kept: usize,
209 pub unreachable_prune_ms: u64,
211 pub unreachable_prune_ran: bool,
213}
214
215#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
217#[non_exhaustive]
218pub struct CommitGraphOptions {
219 pub changed_paths: bool,
221 pub dry_run: bool,
223}
224
225impl CommitGraphOptions {
226 #[must_use]
228 pub fn with_changed_paths(changed_paths: bool) -> Self {
229 Self {
230 changed_paths,
231 ..Self::default()
232 }
233 }
234
235 #[must_use]
237 pub fn with_dry_run(dry_run: bool) -> Self {
238 Self {
239 dry_run,
240 ..Self::default()
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247#[non_exhaustive]
248pub struct CommitGraphWriteReport {
249 pub path: PathBuf,
251 pub commit_count: usize,
253 pub removed_chain: bool,
255 pub bloom_stats: Option<BloomWriteStats>,
258 pub skipped: Option<CommitGraphSkipReason>,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268#[non_exhaustive]
269pub enum CommitGraphSkipReason {
270 Shallow,
272 Grafts,
274 ReplaceRefs,
276}
277
278impl CommitGraphSkipReason {
279 #[must_use]
281 pub fn as_str(self) -> &'static str {
282 match self {
283 Self::Shallow => "shallow",
284 Self::Grafts => "grafts",
285 Self::ReplaceRefs => "replace-refs",
286 }
287 }
288
289 pub(crate) fn detect(
295 git_dir: &Path,
296 has_replace_refs: impl FnOnce() -> Result<bool>,
297 ) -> Result<Option<Self>> {
298 let common = crate::repo::common_git_dir_for_config(git_dir);
299 if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
300 return Ok(Some(Self::Shallow));
301 }
302 if !crate::rev_parse::load_graft_parents(&common).is_empty() {
303 return Ok(Some(Self::Grafts));
304 }
305 Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
306 }
307
308 pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
311 Self::detect(repo.git_dir(), || {
312 let Some(base) = repo.replace_ref_base_if_enabled() else {
313 return Ok(false);
314 };
315 Ok(!repo.references().list(&base)?.is_empty())
316 })
317 }
318}
319
320#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
322#[non_exhaustive]
323pub struct MidxOptions {
324 pub bitmap: bool,
326 pub dry_run: bool,
328 pub version: Option<u8>,
332}
333
334impl MidxOptions {
335 #[must_use]
337 pub fn with_bitmap(bitmap: bool) -> Self {
338 Self {
339 bitmap,
340 ..Self::default()
341 }
342 }
343
344 #[must_use]
346 pub fn with_dry_run(dry_run: bool) -> Self {
347 Self {
348 dry_run,
349 ..Self::default()
350 }
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356#[non_exhaustive]
357pub struct MidxWriteReport {
358 pub path: PathBuf,
360 pub pack_count: usize,
362 pub bitmap: MidxBitmapWriteOutcome,
364}
365
366#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
368#[non_exhaustive]
369pub struct PackRefsOptions {
370 pub all: bool,
372 pub dry_run: bool,
374}
375
376impl PackRefsOptions {
377 #[must_use]
379 pub fn with_all(all: bool) -> Self {
380 Self {
381 all,
382 ..Self::default()
383 }
384 }
385
386 #[must_use]
388 pub fn with_dry_run(dry_run: bool) -> Self {
389 Self {
390 dry_run,
391 ..Self::default()
392 }
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
398#[non_exhaustive]
399pub struct PackRefsReport {
400 pub path: PathBuf,
402 pub refs_packed: usize,
404 pub refs_pruned: usize,
406}
407
408#[derive(Debug, Clone)]
410#[non_exhaustive]
411pub struct GcOptions {
412 pub repack: RepackOptions,
414 pub expire_reflog: bool,
416 pub now_unix: i64,
418 pub pack_refs: bool,
420 pub write_commit_graph: bool,
425 pub drop_large_blobs: bool,
431}
432
433impl Default for GcOptions {
434 fn default() -> Self {
435 Self {
436 repack: RepackOptions {
437 auto_skip: true,
438 ..RepackOptions::default()
439 },
440 expire_reflog: true,
441 now_unix: 0,
442 pack_refs: true,
443 write_commit_graph: true,
444 drop_large_blobs: true,
445 }
446 }
447}
448
449impl GcOptions {
450 #[must_use]
452 pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
453 Self {
454 repack: RepackOptions {
455 dry_run,
456 auto_skip: true,
457 ..RepackOptions::default()
458 },
459 expire_reflog: true,
460 now_unix,
461 ..Self::default()
462 }
463 }
464
465 #[must_use]
467 pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
468 Self {
469 repack: RepackOptions {
470 auto_skip: true,
471 ..RepackOptions::default()
472 },
473 expire_reflog,
474 now_unix,
475 ..Self::default()
476 }
477 }
478
479 #[must_use]
481 pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
482 Self {
483 repack: RepackOptions {
484 dry_run,
485 auto_skip: true,
486 ..RepackOptions::default()
487 },
488 expire_reflog: true,
489 now_unix,
490 pack_refs: true,
491 write_commit_graph: true,
492 ..Self::default()
493 }
494 }
495}
496
497impl PruneOptions {
498 #[must_use]
500 pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
501 Self {
502 older_than,
503 ..Self::default()
504 }
505 }
506}
507
508pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
517 repo_fsck::reachability_roots(repo)
518}
519
520pub(crate) fn bloom_settings_for_commit_graph_write(
521 config: &ConfigSet,
522 changed_paths: bool,
523) -> BloomFilterSettings {
524 let mut bloom = BloomFilterSettings::default();
525 if !changed_paths {
526 return bloom;
527 }
528 if let Some(v) = config
529 .get("commitgraph.changedpathsversion")
530 .and_then(|s| s.trim().parse::<i32>().ok())
531 {
532 if v == 2 {
533 bloom.hash_version = 2;
534 } else if v == 1 {
535 bloom.hash_version = 1;
536 }
537 }
538 bloom
539}
540
541fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
547 repo.odb()
548 .files_objects_dir()
549 .and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
550 .is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
551}
552
553pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
554 config
555 .get("commitgraph.generationversion")
556 .and_then(|s| s.trim().parse::<i32>().ok())
557 .map(|v| v >= 2)
558 .unwrap_or(true)
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
563#[non_exhaustive]
564pub struct BuiltCommitGraph {
565 pub bytes: Vec<u8>,
567 pub commit_count: usize,
569 pub bloom_stats: Option<BloomWriteStats>,
571}
572
573impl BuiltCommitGraph {
574 pub fn to_chain(&self) -> Result<CommitGraphChain> {
582 CommitGraphChain::from_bytes(self.bytes.clone())
583 }
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
588pub enum CommitGraphBuild {
589 Built(BuiltCommitGraph),
591 Skipped(CommitGraphSkipReason),
593}
594
595pub fn build_commit_graph(
617 repo: &Repository,
618 opts: &CommitGraphOptions,
619) -> Result<CommitGraphBuild> {
620 if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
621 return Ok(CommitGraphBuild::Skipped(reason));
622 }
623 let odb = repo.odb();
624 let existing = odb.commit_graph_chain();
625 let commits = collect_reachable_commit_infos_for_repository(repo)?;
626 build_commit_graph_for_commits(
627 odb,
628 &commits,
629 &*repo.config()?,
630 opts.changed_paths,
631 existing.as_deref(),
632 )
633 .map(CommitGraphBuild::Built)
634}
635
636pub(crate) fn build_commit_graph_for_commits(
643 odb: &Odb,
644 infos: &OidHashMap<CommitGraphCommitInfo>,
645 config: &ConfigSet,
646 changed_paths: bool,
647 existing: Option<&CommitGraphChain>,
648) -> Result<BuiltCommitGraph> {
649 let bloom = bloom_settings_for_commit_graph_write(config, changed_paths);
650 let write_generation = commit_graph_writes_generation_data(config);
651 let mut sorted: Vec<ObjectId> = infos.keys().copied().collect();
652 sorted.sort_unstable();
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(
708 repo: &Repository,
709 opts: &CommitGraphOptions,
710) -> Result<CommitGraphWriteReport> {
711 let odb = repo.odb();
712 odb.require_files_primary("commit-graph write")?;
713 let objects_dir = odb
714 .files_objects_dir()
715 .ok_or(Error::UnsupportedObjectStore {
716 operation: "commit-graph write",
717 })?;
718 let path = objects_dir.join("info").join("commit-graph");
719
720 if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
721 let removed_chain = if opts.dry_run {
722 false
723 } else {
724 let removed = remove_commit_graph_files(objects_dir)?;
725 repo.caches().invalidate_commit_graph_cache();
726 removed
727 };
728 return Ok(CommitGraphWriteReport {
729 path,
730 commit_count: 0,
731 removed_chain,
732 bloom_stats: None,
733 skipped: Some(reason),
734 });
735 }
736
737 let commits = collect_reachable_commit_infos_for_repository(repo)?;
738 if opts.dry_run {
739 return Ok(CommitGraphWriteReport {
740 path,
741 commit_count: commits.len(),
742 removed_chain: false,
743 bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
744 skipped: None,
745 });
746 }
747 if commits.is_empty() {
748 return Ok(CommitGraphWriteReport {
749 path,
750 commit_count: 0,
751 removed_chain: false,
752 bloom_stats: None,
753 skipped: None,
754 });
755 }
756
757 let existing = CommitGraphChain::try_load(objects_dir).ok().flatten();
758 let built = build_commit_graph_for_commits(
759 odb,
760 &commits,
761 &*repo.config()?,
762 opts.changed_paths,
763 existing.as_ref(),
764 )?;
765
766 let written_path = atomic_replace_commit_graph(objects_dir, &built.bytes)?;
767 let removed_chain = remove_commit_graph_chain_dir(objects_dir);
768 repo.caches().invalidate_commit_graph_cache();
769
770 Ok(CommitGraphWriteReport {
771 path: written_path,
772 commit_count: built.commit_count,
773 removed_chain,
774 bloom_stats: built.bloom_stats,
775 skipped: None,
776 })
777}
778
779pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
789 let odb = repo.odb();
790 odb.require_files_primary("midx write")?;
791 let requested = match opts.version {
792 Some(v) => Some(v),
793 None => midx_version_from_config(&*repo.config()?)?,
794 };
795 let version = Some(midx_write_version(requested)?);
796 let objects_dir = odb
797 .files_objects_dir()
798 .ok_or(Error::UnsupportedObjectStore {
799 operation: "midx write",
800 })?;
801 let pack_dir = objects_dir.join("pack");
802 let pack_count = fs::read_dir(&pack_dir)
803 .map(|rd| {
804 rd.filter_map(|e| e.ok())
805 .filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
806 .filter(|e| e.path().with_extension("pack").is_file())
807 .count()
808 })
809 .unwrap_or(0);
810
811 let path = pack_dir.join("multi-pack-index");
812 if opts.dry_run {
813 return Ok(MidxWriteReport {
814 path,
815 pack_count,
816 bitmap: MidxBitmapWriteOutcome::NotRequested,
817 });
818 }
819 let write_opts = WriteMultiPackIndexOptions {
820 write_bitmap: opts.bitmap,
821 version,
822 ..WriteMultiPackIndexOptions::default()
823 };
824 let WriteMultiPackIndexResult { bitmap } =
825 write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
826 Ok(MidxWriteReport {
827 path,
828 pack_count,
829 bitmap,
830 })
831}
832
833#[derive(Debug, Clone, Default)]
835#[non_exhaustive]
836pub struct PruneOptions {
837 pub older_than: Option<SystemTime>,
841 pub dry_run: bool,
843}
844
845#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
847#[non_exhaustive]
848pub struct GcPhaseTimings {
849 pub pack_refs_ms: u64,
851 pub reflog_ms: u64,
853 pub repack_ms: u64,
855 pub prune_ms: u64,
857 pub commit_graph_ms: u64,
859 pub drop_large_blobs_ms: u64,
861}
862
863#[derive(Debug, Clone, Default, PartialEq, Eq)]
865pub struct GcStats {
866 pub pack_refs: Option<PackRefsReport>,
868 pub repack: RepackStats,
870 pub prune: PruneStats,
872 pub prune_ran: bool,
874 pub commit_graph: Option<CommitGraphWriteReport>,
876 pub reflog_entries_pruned: usize,
878 pub drop_large_blobs: Option<DropPromisedBlobsReport>,
882 pub phase_timings: GcPhaseTimings,
884}
885
886pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
900 Ok(repack_detail(repo, opts, &NeverCancel)?.report)
901}
902
903fn repack_detail(
904 repo: &Repository,
905 opts: &RepackOptions,
906 cancel: &dyn Cancel,
907) -> Result<RepackDetail> {
908 poll_cancel(&mut NullProgress, cancel)?;
909 let odb = repo.odb();
910 odb.require_files_primary("repack")?;
911 let objects_dir = odb
912 .files_objects_dir()
913 .ok_or(Error::UnsupportedObjectStore {
914 operation: "repack",
915 })?;
916 let pack_dir = objects_dir.join("pack");
917
918 let mut report = RepackReport::default();
919 let loose_before = count_loose_objects(odb)?;
920 let packs_before = count_pack_files(&pack_dir);
921 let pack_inventory = pack_repack_inventory(&pack_dir);
922
923 #[cfg(test)]
924 gc_test_hooks::run(gc_test_hooks::GcPausePoint::AfterInventoryBeforeRoots);
925
926 let roots = collect_maintenance_object_roots(repo)?;
927
928 #[cfg(test)]
929 gc_test_hooks::run(gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll);
930 if roots.is_empty() && loose_before == 0 && packs_before == 0 {
931 return Ok(RepackDetail {
932 report,
933 pack_path: None,
934 prune_packed_loose: 0,
935 unreachable_loose: PruneStats::default(),
936 unreachable_prune_ms: 0,
937 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
938 });
939 }
940
941 let cfg = opts
942 .merged_config
943 .clone()
944 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
945 let mut pack_opts = opts
946 .pack_build
947 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
948 if opts.force_recompress {
949 pack_opts.reuse_deltas = false;
950 }
951
952 let run_incremental = opts.incremental;
953 let shallow = load_shallow_boundaries(repo.git_dir());
954 let reachable = LazyReachable::new(odb, &roots, &shallow);
955
956 let skip_repack = !opts.force_recompress
957 && !opts.write_bitmap
958 && !opts.write_commit_graph
959 && should_auto_skip_repack(
960 loose_before,
961 packs_before,
962 run_incremental,
963 opts.auto_skip,
964 cfg.as_ref(),
965 )
966 && !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
967
968 if skip_repack {
969 reachable.require_roots_exist()?;
972 return Ok(RepackDetail {
973 report,
974 pack_path: None,
975 prune_packed_loose: 0,
976 unreachable_loose: PruneStats::default(),
977 unreachable_prune_ms: 0,
978 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
979 });
980 }
981 let promised = reachable.promised()?;
982 let partial_clone = !promised.is_empty()
983 || cfg.as_ref().is_some_and(|config| {
984 crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config)
985 });
986 if partial_clone {
987 reachable.get()?;
990 }
991 if opts.dry_run {
992 report.objects = if run_incremental {
993 reachable_loose_objects(odb, &reachable)?.len()
994 } else {
995 if !opts.keep_unreachable {
996 reachable
997 .get()?
998 .iter()
999 .filter(|oid| !promised.contains(oid))
1000 .count()
1001 } else {
1002 repack_object_list(odb, &roots, &shallow, true, pack_opts.use_bitmaps, promised)?
1003 .send
1004 .len()
1005 }
1006 };
1007 return Ok(RepackDetail {
1008 report,
1009 pack_path: None,
1010 prune_packed_loose: 0,
1011 unreachable_loose: PruneStats::default(),
1012 unreachable_prune_ms: 0,
1013 unreachable_prune_ran: false,
1014 });
1015 }
1016
1017 let retained_packs = if run_incremental {
1018 HashSet::new()
1019 } else {
1020 packs_holding_available_promises(odb, promised)?
1021 };
1022 poll_cancel(&mut NullProgress, cancel)?;
1023 let built_pack = if run_incremental {
1024 let (loose, name_hashes) = named_reachable_loose_objects(odb, &reachable, &roots)?;
1025 write_staged_repack_pack(
1026 odb,
1027 &pack_dir,
1028 &loose,
1029 &name_hashes,
1030 &pack_opts,
1031 opts.threads,
1032 cfg.as_ref(),
1033 )?
1034 } else {
1035 build_repack_pack(
1036 odb,
1037 &pack_dir,
1038 &roots,
1039 &shallow,
1040 opts.keep_unreachable,
1041 &pack_opts,
1042 opts.threads,
1043 cfg.as_ref(),
1044 promised,
1045 )?
1046 };
1047 let pack_bytes_len = built_pack
1048 .staging_path
1049 .as_ref()
1050 .and_then(|p| std::fs::metadata(p).ok())
1051 .map(|m| m.len() as usize)
1052 .unwrap_or_else(|| built_pack.bytes.len());
1053 let mut prune_packed_loose = 0usize;
1054 let mut unreachable_loose = PruneStats::default();
1055 let mut installed_pack = None;
1056 let pack_trailer_len = 12 + odb.hash_algo().len();
1057 if pack_bytes_len > pack_trailer_len {
1058 poll_cancel(&mut NullProgress, cancel)?;
1059 clear_pack_midx_state(&pack_dir)?;
1060
1061 let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
1062 let ingest_opts = IngestPackOptions {
1063 fix_thin: false,
1064 threads: opts.threads,
1065 merged_config: cfg.clone(),
1066 environment: opts.environment.clone(),
1067 skip_post_index_verify: trusted_rows.is_some(),
1069 trusted_index_rows: trusted_rows,
1070 ..IngestPackOptions::default()
1071 };
1072 let mut progress = NullProgress;
1073 let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
1074 install_pack_path_with_progress(
1075 staging,
1076 odb,
1077 &ingest_opts,
1078 Some(&mut progress),
1079 Some(cancel),
1080 )?
1081 } else {
1082 install_pack_bytes_with_progress(
1083 built_pack.bytes,
1084 odb,
1085 &ingest_opts,
1086 Some(&mut progress),
1087 Some(cancel),
1088 )?
1089 };
1090 report.packs_written = 1;
1091 report.objects = ingested.object_ids.len();
1092 let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
1093 operation: "repack",
1094 })?;
1095
1096 let idx_path = pack_path.with_extension("idx");
1097 if cfg
1098 .as_ref()
1099 .is_none_or(|c| c.pack_write_reverse_index_default())
1100 {
1101 write_pack_rev_sidecar_for_index_path(&idx_path)?;
1102 }
1103
1104 if opts.write_bitmap && promised.is_empty() && !AlternateSources::load(odb)?.in_use() {
1105 if opts.incremental && packs_before > 0 {
1106 let version = match cfg.as_ref() {
1110 Some(c) => midx_version_from_config(c)?,
1111 None => None,
1112 };
1113 let midx_opts = WriteMultiPackIndexOptions {
1114 write_bitmap: true,
1115 version,
1116 ..WriteMultiPackIndexOptions::default()
1117 };
1118 let WriteMultiPackIndexResult { bitmap: _ } =
1119 write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
1120 } else {
1121 let bitmap_opts = PackBitmapWriteOptions {
1122 reachability_roots: Some(roots.clone()),
1123 ..PackBitmapWriteOptions::default()
1124 };
1125 let now = opts
1126 .now
1127 .map(offset_to_system_time)
1128 .unwrap_or(SystemTime::UNIX_EPOCH);
1129 match PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now) {
1130 Ok(_) => {}
1131 Err(PackBitmapWriteError::NotClosed { .. }) => {
1132 }
1135 Err(e) => return Err(Error::CorruptObject(e.to_string())),
1136 }
1137 }
1138 }
1139
1140 let retention = if opts.keep_unreachable {
1141 UnreachablePackRetention::KeepAll
1142 } else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1143 UnreachablePackRetention::KeepGraceProtected(cutoff)
1144 } else {
1145 UnreachablePackRetention::DropAll
1146 };
1147 if !opts.incremental {
1149 odb.loosen_unreachable_from_superseded_packs(
1150 &pack_dir,
1151 &pack_path,
1152 &reachable,
1153 retention,
1154 &retained_packs,
1155 &pack_inventory,
1156 )?;
1157 }
1158
1159 odb.invalidate_packs();
1161
1162 report.packs_removed = if opts.incremental {
1163 0
1164 } else {
1165 remove_superseded_pack_files_retaining(
1166 &pack_dir,
1167 &pack_path,
1168 &retained_packs,
1169 &pack_inventory,
1170 )?
1171 };
1172
1173 prune_packed_loose =
1174 prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
1175
1176 installed_pack = Some(pack_path);
1177
1178 if opts.write_commit_graph {
1179 write_commit_graph(
1180 repo,
1181 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
1182 repo,
1183 )),
1184 )?;
1185 }
1186 }
1187
1188 let mut unreachable_prune_ms = 0u64;
1189 let mut unreachable_prune_ran = false;
1190 if !opts.keep_unreachable {
1191 let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
1192 let prune_start = Instant::now();
1193 unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
1194 unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
1195 unreachable_prune_ran = true;
1196 }
1197
1198 report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
1199 Ok(RepackDetail {
1200 report,
1201 pack_path: installed_pack,
1202 prune_packed_loose,
1203 unreachable_loose,
1204 unreachable_prune_ms,
1205 unreachable_prune_ran,
1206 })
1207}
1208
1209pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
1211 repack_all_with_cancel(repo, opts, &NeverCancel)
1212}
1213
1214pub fn repack_all_with_cancel(
1216 repo: &Repository,
1217 opts: &RepackOptions,
1218 cancel: &dyn Cancel,
1219) -> Result<RepackStats> {
1220 let odb = repo.odb();
1221 let objects_dir = odb
1222 .files_objects_dir()
1223 .ok_or(Error::UnsupportedObjectStore {
1224 operation: "repack",
1225 })?;
1226 let pack_dir = objects_dir.join("pack");
1227 let mut stats = RepackStats {
1228 loose_before: count_loose_objects(odb)?,
1229 packs_before: count_pack_files(&pack_dir),
1230 ..RepackStats::default()
1231 };
1232 if opts.dry_run {
1233 let count = repack_detail(repo, opts, cancel)?.report.objects;
1234 stats.objects_packed = (count > 0).then_some(count);
1235 return Ok(stats);
1236 }
1237 let detail = repack_detail(repo, opts, cancel)?;
1238 let report = detail.report;
1239 stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
1240 stats.removed_pack_files = report.packs_removed;
1241 stats.pruned_loose = detail.prune_packed_loose;
1242 stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
1243 stats.unreachable_loose_kept = detail.unreachable_loose.kept;
1244 stats.unreachable_prune_ms = detail.unreachable_prune_ms;
1245 stats.unreachable_prune_ran = detail.unreachable_prune_ran;
1246 stats.pack_path = detail.pack_path;
1247 stats.pack_bytes = stats
1248 .pack_path
1249 .as_ref()
1250 .and_then(|p| p.metadata().ok().map(|m| m.len()));
1251 Ok(stats)
1252}
1253
1254fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
1255 SystemTime::UNIX_EPOCH
1256 + std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
1257}
1258
1259fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
1260 match policy {
1261 GcLoosePrunePolicy::Disabled => {
1262 repack.keep_unreachable = true;
1263 }
1264 GcLoosePrunePolicy::Enabled { keep_newer_than } => {
1265 repack.keep_unreachable = false;
1266 repack.prune_older_than = keep_newer_than.map(|t| {
1267 let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
1268 OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
1269 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
1270 });
1271 }
1272 }
1273}
1274
1275fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1277 let repack_key = canonical_key("repack.writeBitmaps").ok();
1278 let pack_key = canonical_key("pack.writeBitmaps").ok();
1279 let mut last: Option<bool> = None;
1280 for entry in config.entries() {
1281 let key = entry.key.as_str();
1282 if repack_key.as_deref() == Some(key) || pack_key.as_deref() == Some(key) {
1283 let raw = entry.value.as_deref().unwrap_or("true").trim().to_owned();
1284 last = Some(parse_bool(&raw).map_err(|e| e.for_key(key))?);
1285 }
1286 }
1287 Ok(last.unwrap_or_else(|| repo.work_tree().is_none()))
1288}
1289
1290fn gc_should_pack_refs(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1291 let Some(spec) = config.get("gc.packRefs") else {
1292 return Ok(true);
1293 };
1294 if spec.eq_ignore_ascii_case("notbare") {
1295 return Ok(repo.work_tree().is_some());
1296 }
1297 Ok(parse_bool(&spec).map_err(|e| e.for_key("gc.packRefs"))?)
1298}
1299
1300fn gc_should_write_commit_graph(config: &ConfigSet) -> Result<bool> {
1301 if config.get_bool("gc.writeCommitGraph").transpose()? == Some(false) {
1302 return Ok(false);
1303 }
1304 Ok(config.get_bool("core.commitGraph").transpose()? != Some(false))
1305}
1306
1307fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1310 let loose = odb.enumerate_local_loose_objects()?;
1311 if loose.is_empty() {
1312 return Ok(Vec::new());
1313 }
1314 let promised = reachable.promised()?;
1315 let reachable = reachable.get()?;
1316 let alternates = AlternateSources::load(odb)?;
1317 Ok(loose
1318 .into_iter()
1319 .map(|(oid, _)| oid)
1320 .filter(|oid| {
1321 reachable.contains(oid) && !promised.contains(oid) && !alternates.contains(oid)
1322 })
1323 .collect())
1324}
1325
1326fn named_reachable_loose_objects(
1334 odb: &Odb,
1335 reachable: &LazyReachable<'_>,
1336 roots: &[ObjectId],
1337) -> Result<(Vec<ObjectId>, HashMap<ObjectId, u32>)> {
1338 let loose = reachable_loose_objects(odb, reachable)?;
1339 let mut pending: OidHashSet = loose.iter().copied().collect();
1340 let mut order = Vec::with_capacity(loose.len());
1341 let mut names = HashMap::new();
1342 let mut commits = Vec::new();
1343 let mut root_trees = Vec::new();
1344 let mut stack: Vec<ObjectId> = roots
1345 .iter()
1346 .filter(|oid| pending.contains(*oid))
1347 .copied()
1348 .collect();
1349 let mut seen = OidHashSet::default();
1350 while let Some(oid) = stack.pop() {
1351 if !seen.insert(oid) {
1352 continue;
1353 }
1354 match odb.read_info(&oid)?.kind {
1355 ObjectKind::Commit => {
1356 let obj = odb.read(&oid)?;
1357 let commit = CommitRef::parse(&obj.data)?;
1358 commits.push((
1359 commit.committer_unix_seconds_for_ordering(),
1360 oid,
1361 commit.tree(),
1362 ));
1363 stack.extend(commit.parents().iter().filter(|p| pending.contains(*p)));
1364 }
1365 ObjectKind::Tag => {
1366 let obj = odb.read(&oid)?;
1367 pending.remove(&oid);
1368 order.push(oid);
1369 stack.extend(tag_object_line_oid(&obj.data).filter(|t| pending.contains(t)));
1370 }
1371 ObjectKind::Tree => root_trees.push(oid),
1372 ObjectKind::Blob => {}
1373 }
1374 }
1375 commits.sort_by_key(|&(time, _, _)| std::cmp::Reverse(time));
1376 for &(_, oid, _) in &commits {
1377 pending.remove(&oid);
1378 order.push(oid);
1379 }
1380 let hash_len = odb.hash_algo().len();
1381 let mut trees: Vec<(ObjectId, Option<u32>)> = Vec::new();
1382 for tree in commits.iter().map(|&(_, _, tree)| tree).chain(root_trees) {
1383 trees.push((tree, None));
1384 while let Some((tree, name_hash)) = trees.pop() {
1385 if !pending.remove(&tree) {
1386 continue;
1387 }
1388 order.push(tree);
1389 if let Some(hash) = name_hash {
1390 names.insert(tree, hash);
1391 }
1392 let obj = odb.read(&tree)?;
1393 for entry in TreeRefIter::with_oid_len(&obj.data, hash_len) {
1394 let entry = entry?;
1395 if FileMode::new(entry.mode).is_gitlink() || !pending.contains(&entry.oid) {
1396 continue;
1397 }
1398 let hash = pack_name_hash_child(name_hash, entry.name);
1399 if entry.mode & S_IFMT == S_IFDIR {
1400 trees.push((entry.oid, Some(hash)));
1401 } else {
1402 pending.remove(&entry.oid);
1403 order.push(entry.oid);
1404 names.insert(entry.oid, hash);
1405 }
1406 }
1407 }
1408 }
1409 order.extend(loose.into_iter().filter(|oid| pending.contains(oid)));
1410 Ok((order, names))
1411}
1412
1413fn should_auto_skip_repack(
1418 loose_objects: usize,
1419 pack_count: usize,
1420 incremental: bool,
1421 auto_skip: bool,
1422 _config: Option<&ConfigSet>,
1423) -> bool {
1424 if !auto_skip || incremental {
1425 return false;
1426 }
1427 if pack_count > 1 {
1428 return false;
1429 }
1430 if pack_count == 1 {
1431 return loose_objects == 0;
1432 }
1433 loose_objects == 0
1434}
1435
1436fn skipping_repack_would_retain_prunable_packed_objects(
1441 odb: &Odb,
1442 pack_dir: &Path,
1443 reachable: &LazyReachable<'_>,
1444 opts: &RepackOptions,
1445) -> Result<bool> {
1446 if opts.keep_unreachable {
1447 return Ok(false);
1448 }
1449 let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1450 UnreachablePackRetention::KeepGraceProtected(cutoff)
1451 } else {
1452 UnreachablePackRetention::DropAll
1453 };
1454 let indexes = odb.local_pack_indexes()?;
1455 for idx in indexes {
1456 if idx.is_promisor || pack_has_keep_marker(pack_dir, &idx.pack_path) {
1457 continue;
1458 }
1459 let pack_mtime = fs::metadata(&idx.pack_path)
1460 .and_then(|m| m.modified())
1461 .unwrap_or(SystemTime::UNIX_EPOCH);
1462 let drop_unreachable_from_pack = match retention {
1463 UnreachablePackRetention::KeepAll => false,
1464 UnreachablePackRetention::DropAll => true,
1465 UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1466 };
1467 if !drop_unreachable_from_pack {
1468 continue;
1469 }
1470 let reachable = reachable.get()?;
1471 for entry in idx.iter() {
1472 let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1473 continue;
1474 };
1475 if !reachable.contains(&oid) {
1476 return Ok(true);
1477 }
1478 }
1479 }
1480 Ok(false)
1481}
1482
1483pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1492 let odb = repo.odb();
1493 odb.require_files_primary("prune")?;
1494 let cfg = repo.config()?;
1495 let now = gc_now_unix(0);
1496 let keep_newer_than = match opts.older_than {
1497 Some(t) => Some(t),
1498 None => match gc_loose_prune_policy(&cfg, now)? {
1499 GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1500 GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1501 },
1502 };
1503 let roots = collect_maintenance_object_roots(repo)?;
1504 let shallow = load_shallow_boundaries(repo.git_dir());
1505 let mut stats = prune_loose_unreachable_with(
1506 odb,
1507 &LazyReachable::new(odb, &roots, &shallow),
1508 keep_newer_than,
1509 opts.dry_run,
1510 )?;
1511 let objects_dir = odb
1512 .files_objects_dir()
1513 .ok_or(Error::UnsupportedObjectStore {
1514 operation: "prune temporary objects",
1515 })?;
1516 let temp =
1517 prune_stale_temporary_objects_with_cutoff(objects_dir, keep_newer_than, opts.dry_run)?;
1518 stats.pruned += temp.pruned;
1519 stats.kept += temp.kept;
1520 Ok(stats)
1521}
1522
1523fn prune_stale_temporary_objects_with_cutoff(
1531 objects_dir: &Path,
1532 keep_newer_than: Option<SystemTime>,
1533 dry_run: bool,
1534) -> Result<PruneStats> {
1535 let mut stats = PruneStats::default();
1536 let mut consider = |path: &Path| -> Result<()> {
1537 let meta = match fs::symlink_metadata(path) {
1538 Ok(m) => m,
1539 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1540 Err(e) => return Err(Error::Io(e)),
1541 };
1542 if temporary_object_expired(&meta, keep_newer_than) {
1543 if dry_run {
1544 stats.pruned += 1;
1545 } else if meta.is_dir() {
1546 fs::remove_dir_all(path).map_err(Error::Io)?;
1547 stats.pruned += 1;
1548 } else {
1549 fs::remove_file(path).map_err(Error::Io)?;
1550 stats.pruned += 1;
1551 }
1552 } else {
1553 stats.kept += 1;
1554 }
1555 Ok(())
1556 };
1557
1558 if let Ok(entries) = fs::read_dir(objects_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 } else if name.len() == 2 && name.as_bytes().iter().all(|b| b.is_ascii_hexdigit()) {
1565 if let Ok(loose) = fs::read_dir(entry.path()) {
1566 for loose_entry in loose.flatten() {
1567 let loose_name = loose_entry.file_name();
1568 if loose_name.to_string_lossy().starts_with("tmp_obj_") {
1569 consider(&loose_entry.path())?;
1570 }
1571 }
1572 }
1573 }
1574 }
1575 }
1576
1577 let pack_dir = objects_dir.join("pack");
1578 if let Ok(entries) = fs::read_dir(&pack_dir) {
1579 for entry in entries.flatten() {
1580 let name = entry.file_name();
1581 let name = name.to_string_lossy();
1582 if name.starts_with("tmp_") {
1583 consider(&entry.path())?;
1584 continue;
1585 }
1586 if !is_grit_pack_install_stage_name(&name) {
1587 continue;
1588 }
1589 let meta = match fs::symlink_metadata(entry.path()) {
1590 Ok(m) => m,
1591 Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
1592 Err(e) => return Err(Error::Io(e)),
1593 };
1594 if meta.is_dir() {
1595 consider(&entry.path())?;
1596 }
1597 }
1598 }
1599
1600 let incoming = objects_dir.join("incoming");
1601 if let Ok(entries) = fs::read_dir(&incoming) {
1602 for entry in entries.flatten() {
1603 let name = entry.file_name();
1604 let name = name.to_string_lossy();
1605 if name.starts_with("pack-receive-") && name.ends_with(".tmp") {
1606 consider(&entry.path())?;
1607 }
1608 }
1609 }
1610 Ok(stats)
1611}
1612
1613fn is_grit_pack_install_stage_name(name: &str) -> bool {
1615 name.starts_with(".pack-") && name.contains("-install-")
1616}
1617
1618fn temporary_object_expired(meta: &fs::Metadata, keep_newer_than: Option<SystemTime>) -> bool {
1619 match keep_newer_than {
1620 None => true,
1621 Some(cutoff) => meta
1622 .modified()
1623 .ok()
1624 .is_none_or(|modified| modified <= cutoff),
1625 }
1626}
1627
1628fn duration_ms(d: std::time::Duration) -> u64 {
1629 d.as_millis() as u64
1630}
1631
1632pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1645 gc_with_cancel(repo, opts, &NeverCancel)
1646}
1647
1648pub fn gc_with_cancel(repo: &Repository, opts: &GcOptions, cancel: &dyn Cancel) -> Result<GcStats> {
1651 repo.odb().require_files_primary("gc")?;
1652 let now = gc_now_unix(opts.now_unix);
1653 let _lock = if opts.repack.dry_run {
1654 None
1655 } else {
1656 match GcLock::try_acquire(repo, now)? {
1657 GcLockAttempt::Acquired(lock) => Some(lock),
1658 GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1659 }
1660 };
1661 gc_locked(repo, opts, now, cancel)
1662}
1663
1664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1666pub enum AutoGcReason {
1667 TooManyLoose {
1670 estimate: usize,
1672 limit: usize,
1674 },
1675 TooManyPacks {
1678 count: usize,
1680 limit: usize,
1682 },
1683}
1684
1685const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1687
1688pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1703 let cfg = repo.config()?;
1704 let Some(threshold) = cfg.gc_auto_threshold() else {
1705 return Ok(None);
1706 };
1707 let Some(objects_dir) = repo.odb().files_objects_dir() else {
1708 return Ok(None);
1709 };
1710 if let Some(limit) = cfg.gc_auto_pack_limit() {
1711 let count = count_pack_files(&objects_dir.join("pack"));
1712 if count > limit {
1713 return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1714 }
1715 }
1716 let name_len = repo.odb().hash_algo().hex_len() - 2;
1717 let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1718 if sampled > threshold.div_ceil(256) {
1719 return Ok(Some(AutoGcReason::TooManyLoose {
1720 estimate: sampled.saturating_mul(256),
1721 limit: threshold,
1722 }));
1723 }
1724 Ok(None)
1725}
1726
1727fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1729 let entries = match fs::read_dir(dir) {
1730 Ok(entries) => entries,
1731 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1732 Err(e) => return Err(Error::Io(e)),
1733 };
1734 Ok(entries
1735 .filter_map(|e| e.ok())
1736 .filter(|e| {
1737 let name = e.file_name();
1738 let name = name.as_encoded_bytes();
1739 name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1740 })
1741 .count())
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq)]
1746#[non_exhaustive]
1747pub enum AutoGcOutcome {
1748 NotNeeded,
1750 AlreadyRunning(GcLockHolder),
1752 DeclinedByHook {
1754 status: i32,
1756 output: crate::hooks::HookOutput,
1758 },
1759 Ran {
1761 reason: AutoGcReason,
1763 stats: Box<GcStats>,
1765 },
1766}
1767
1768pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1788 let Some(reason) = auto_gc_needed(repo)? else {
1789 return Ok(AutoGcOutcome::NotNeeded);
1790 };
1791 let now = gc_now_unix(opts.now_unix);
1792 let dry_run = opts.repack.dry_run;
1793 let _lock = if dry_run {
1794 None
1795 } else {
1796 match GcLock::try_acquire(repo, now)? {
1797 GcLockAttempt::Acquired(lock) => Some(lock),
1798 GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1799 }
1800 };
1801 if !dry_run {
1802 if let crate::hooks::HookResult::Failed { status, output } =
1803 crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1804 {
1805 return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1806 }
1807 }
1808 let mut run_opts = opts.clone();
1809 if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1810 run_opts.repack.incremental = true;
1811 run_opts.repack.write_bitmap = false;
1812 }
1813 let stats = gc_locked(repo, &run_opts, now, &NeverCancel)?;
1814 Ok(AutoGcOutcome::Ran {
1815 reason,
1816 stats: Box::new(stats),
1817 })
1818}
1819
1820#[derive(Debug, Clone, PartialEq, Eq)]
1822pub struct GcLockHolder {
1823 pub pid: Option<u64>,
1825 pub host: Option<String>,
1827}
1828
1829#[derive(Debug)]
1831#[must_use]
1832pub enum GcLockAttempt {
1833 Acquired(GcLock),
1835 Held(GcLockHolder),
1837}
1838
1839const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1841
1842#[derive(Debug)]
1848pub struct GcLock {
1849 path: PathBuf,
1850}
1851
1852impl GcLock {
1853 pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1861 let path = repo.common_git_dir().join("gc.pid");
1862 let mut lock = match crate::lockfile::LockFile::acquire(
1863 &path.with_extension("pid.lock"),
1864 crate::lockfile::LockWait::Immediately,
1865 ) {
1866 Ok(lock) => lock,
1867 Err(crate::lockfile::LockError::Held { .. }) => {
1868 return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1869 GcLockHolder {
1870 pid: None,
1871 host: None,
1872 },
1873 )));
1874 }
1875 Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1876 };
1877 let host = local_host_name();
1878 if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1879 lock.rollback();
1880 return Ok(GcLockAttempt::Held(holder));
1881 }
1882 use std::io::Write as _;
1883 write!(lock, "{} {host}", std::process::id())?;
1884 lock.commit_to(&path).map_err(|e| match e {
1885 crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1886 held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1887 })?;
1888 Ok(GcLockAttempt::Acquired(GcLock { path }))
1889 }
1890
1891 #[must_use]
1897 pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1898 let path = repo.common_git_dir().join("gc.pid");
1899 live_gc_pid_holder(&path, &local_host_name(), now_unix)
1900 }
1901}
1902
1903impl Drop for GcLock {
1904 fn drop(&mut self) {
1905 let _ = fs::remove_file(&self.path);
1906 }
1907}
1908
1909fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1910 let raw = fs::read_to_string(path).ok()?;
1911 let mut fields = raw.split_whitespace();
1912 let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1913 let host = fields.next().map(str::to_owned);
1914 Some(GcLockHolder { pid, host })
1915}
1916
1917fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1919 let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1920 let mtime = OffsetDateTime::from(mtime);
1921 let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1922 if now - mtime > GC_PID_STALE_AFTER {
1923 return None;
1924 }
1925 let holder = read_gc_pid(path)?;
1926 let pid = holder.pid?;
1927 let same_host = holder.host.as_deref() == Some(host);
1928 if same_host && !crate::lockfile::is_process_running(pid) {
1929 return None;
1930 }
1931 Some(holder)
1932}
1933
1934fn local_host_name() -> String {
1937 #[cfg(unix)]
1938 {
1939 if let Ok(uts) = nix::sys::utsname::uname() {
1940 if let Some(name) = uts.nodename().to_str() {
1941 return name.to_owned();
1942 }
1943 }
1944 }
1945 #[cfg(windows)]
1946 {
1947 if let Some(name) = windows_dns_host_name() {
1948 return name;
1949 }
1950 }
1951 "localhost".to_owned()
1952}
1953
1954#[cfg(windows)]
1956fn windows_dns_host_name() -> Option<String> {
1957 use windows_sys::Win32::System::SystemInformation::{
1958 ComputerNameDnsHostname, GetComputerNameExW,
1959 };
1960 let mut len: u32 = 0;
1961 unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1963 if len == 0 {
1964 return None;
1965 }
1966 let mut buf = vec![0u16; len as usize];
1967 let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1969 (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1970}
1971
1972fn gc_locked(
1973 repo: &Repository,
1974 opts: &GcOptions,
1975 now: i64,
1976 cancel: &dyn Cancel,
1977) -> Result<GcStats> {
1978 poll_cancel(&mut NullProgress, cancel)?;
1979 let mut stats = GcStats::default();
1980 let dry_run = opts.repack.dry_run;
1981 let _ = crate::porcelain::commit::reap_stale_next_index_files(repo, dry_run)?;
1982 let cfg = repo.config()?;
1983 let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1984 let drop_plan = if opts.drop_large_blobs {
1986 Some(drop_blobs::plan_drop_for_gc(&cfg, opts.now_unix)?)
1987 } else {
1988 None
1989 };
1990 if opts.pack_refs && gc_should_pack_refs(repo, &cfg)? && !dry_run {
1991 poll_cancel(&mut NullProgress, cancel)?;
1992 let start = Instant::now();
1993 stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1994 stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1995 }
1996
1997 if opts.expire_reflog {
1998 let start = Instant::now();
1999 stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
2000 stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
2001 }
2002
2003 let mut repack_opts = opts.repack.clone();
2004 apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
2005 if repack_opts.now.is_none() {
2006 repack_opts.now =
2007 Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
2008 }
2009 if !repack_opts.write_bitmap && !repack_opts.incremental {
2012 repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg)?;
2013 }
2014 poll_cancel(&mut NullProgress, cancel)?;
2015 let repack_start = Instant::now();
2016 stats.repack = repack_all_with_cancel(repo, &repack_opts, cancel)?;
2017 let repack_total_ms = duration_ms(repack_start.elapsed());
2018 stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
2019 stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
2020
2021 if let Some(plan) = drop_plan {
2023 let start = Instant::now();
2024 stats.drop_large_blobs = Some(drop_blobs::run_drop_for_gc(
2025 repo,
2026 &cfg,
2027 plan,
2028 dry_run,
2029 opts.repack.threads,
2030 )?);
2031 stats.phase_timings.drop_large_blobs_ms = duration_ms(start.elapsed());
2032 }
2033
2034 stats.prune_ran = stats.repack.unreachable_prune_ran;
2037 stats.prune = PruneStats {
2038 pruned: stats.repack.unreachable_loose_pruned,
2039 kept: stats.repack.unreachable_loose_kept,
2040 };
2041
2042 if let GcLoosePrunePolicy::Enabled { keep_newer_than } = &prune_policy {
2043 let temp = prune_stale_temporary_objects_with_cutoff(
2044 repo.odb()
2045 .files_objects_dir()
2046 .ok_or(Error::UnsupportedObjectStore { operation: "gc" })?,
2047 *keep_newer_than,
2048 dry_run,
2049 )?;
2050 stats.prune.pruned += temp.pruned;
2051 stats.prune.kept += temp.kept;
2052 }
2053
2054 if dry_run {
2055 return Ok(stats);
2056 }
2057
2058 if opts.write_commit_graph && gc_should_write_commit_graph(&cfg)? {
2059 let start = Instant::now();
2060 stats.commit_graph = Some(write_commit_graph(
2061 repo,
2062 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
2063 )?);
2064 stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
2065 }
2066 Ok(stats)
2067}
2068
2069enum GcLoosePrunePolicy {
2071 Disabled,
2073 Enabled {
2075 keep_newer_than: Option<SystemTime>,
2077 },
2078}
2079
2080fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
2081 let spec = config
2082 .get("gc.pruneExpire")
2083 .map(|s| s.trim().to_owned())
2084 .filter(|s| !s.is_empty())
2085 .unwrap_or_else(|| "2.weeks.ago".to_owned());
2086 if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
2087 return Ok(GcLoosePrunePolicy::Disabled);
2088 }
2089 if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
2091 return Ok(GcLoosePrunePolicy::Enabled {
2092 keep_newer_than: None,
2093 });
2094 }
2095 let mut err = 0;
2096 let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
2097 if err != 0 {
2098 return Err(Error::Config(ConfigError::InvalidDate {
2099 key: "gc.pruneExpire".to_owned(),
2100 value: spec,
2101 }));
2102 }
2103 let keep_newer_than = SystemTime::UNIX_EPOCH
2104 .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
2105 Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
2106}
2107
2108fn gc_now_unix(now_unix: i64) -> i64 {
2109 if now_unix != 0 {
2110 return now_unix;
2111 }
2112 time::OffsetDateTime::now_utc().unix_timestamp()
2113}
2114
2115fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
2116 let cfg = repo.config()?;
2117 let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
2118 let params = ReflogExpireParams {
2119 stale_fix: false,
2120 dry_run,
2121 verbose: false,
2122 };
2123 let mut total = 0usize;
2124 let cache = ReflogExpiryCache::default();
2125 for refname in reflog_refs_on_store(repo.refs())? {
2126 let result = expire_reflog_git_with_cache(
2127 repo,
2128 &refname,
2129 ¶ms,
2130 None,
2131 None,
2132 &gc_cfg.patterns,
2133 gc_cfg.global_total,
2134 gc_cfg.global_unreachable,
2135 now,
2136 &cache,
2137 )?;
2138 total += result.pruned;
2139 }
2140 Ok(total)
2141}
2142
2143fn count_loose_objects(odb: &Odb) -> Result<usize> {
2144 Ok(odb.enumerate_local_loose_objects()?.len())
2145}
2146
2147#[allow(clippy::too_many_arguments)]
2150fn build_repack_pack(
2151 odb: &Odb,
2152 pack_dir: &Path,
2153 roots: &[ObjectId],
2154 shallow: &HashSet<ObjectId>,
2155 keep_unreachable: bool,
2156 opts: &PackBuildOptions,
2157 threads: Option<usize>,
2158 cfg: Option<&ConfigSet>,
2159 promised: &HashSet<ObjectId>,
2160) -> Result<SerializedDeltaPack> {
2161 let selection = repack_object_list(
2162 odb,
2163 roots,
2164 shallow,
2165 keep_unreachable,
2166 opts.use_bitmaps,
2167 promised,
2168 )?;
2169 write_staged_repack_pack(
2170 odb,
2171 pack_dir,
2172 &selection.send,
2173 &selection.name_hashes,
2174 opts,
2175 threads,
2176 cfg,
2177 )
2178}
2179
2180pub(crate) fn write_staged_repack_pack(
2193 odb: &Odb,
2194 pack_dir: &Path,
2195 objects: &[ObjectId],
2196 name_hashes: &HashMap<ObjectId, u32>,
2197 opts: &PackBuildOptions,
2198 threads: Option<usize>,
2199 cfg: Option<&ConfigSet>,
2200) -> Result<SerializedDeltaPack> {
2201 if objects.is_empty() {
2202 return Ok(SerializedDeltaPack {
2203 bytes: Vec::new(),
2204 index_rows: Vec::new(),
2205 staging_path: None,
2206 });
2207 }
2208 odb.clear_delta_base_cache();
2209 let max_depth = if opts.delta { opts.max_depth } else { 0 };
2210 let settings = RepackPackSettings {
2211 window: opts.window,
2212 max_depth,
2213 reuse_deltas: opts.reuse_deltas,
2214 reuse_objects: opts.reuse_objects,
2215 use_ofs_delta: opts.use_ofs_delta,
2216 window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
2217 search_threads: pack_delta_search_parallel_options(odb, threads)?
2218 .threads
2219 .get(),
2220 write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
2221 };
2222 let git_dir = odb
2223 .config_git_dir()
2224 .map(Path::to_path_buf)
2225 .unwrap_or_else(|| {
2226 pack_dir
2227 .parent()
2228 .and_then(|objects| objects.parent())
2229 .map(Path::to_path_buf)
2230 .unwrap_or_else(|| pack_dir.to_path_buf())
2231 });
2232 crate::git_dir::io_error::io_with_git_path(
2233 &git_dir,
2234 pack_dir,
2235 crate::git_dir::GitDirPathOperation::GcPack,
2236 fs::create_dir_all(pack_dir),
2237 )?;
2238 let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
2239 let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
2240 match written {
2241 Ok(index_rows) => Ok(SerializedDeltaPack {
2242 bytes: Vec::new(),
2243 index_rows,
2244 staging_path: Some(staging),
2245 }),
2246 Err(err) => {
2247 let _ = fs::remove_file(&staging);
2248 Err(err)
2249 }
2250 }
2251}
2252
2253fn repack_object_list(
2257 odb: &Odb,
2258 roots: &[ObjectId],
2259 shallow: &HashSet<ObjectId>,
2260 keep_unreachable: bool,
2261 use_bitmaps: bool,
2262 promised: &HashSet<ObjectId>,
2263) -> Result<PackObjectSelection> {
2264 let mut roots: Vec<_> = roots
2267 .iter()
2268 .copied()
2269 .filter(|oid| !promised.contains(oid))
2270 .collect();
2271 roots.sort_unstable();
2272 let alternates = AlternateSources::load(odb)?;
2273 let use_bitmaps = use_bitmaps && promised.is_empty() && !alternates.in_use();
2274 let mut selection = enumerate_pack_objects_naming(
2275 odb,
2276 &roots,
2277 &[],
2278 &PackEnumerateOptions {
2279 filter: None,
2280 shallow_grafts: shallow,
2281 have_shallow_grafts: Some(shallow),
2282 use_bitmaps,
2283 exclude_objects: Some(promised),
2284 peer_odb: None,
2285 have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
2286 },
2287 NameTrees::Yes,
2288 true,
2289 &crate::progress::CancelSignal::never(),
2290 )?;
2291 if keep_unreachable {
2292 append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
2293 }
2294 Ok(selection)
2295}
2296
2297fn append_repack_unreachable_retention(
2299 odb: &Odb,
2300 send: &mut Vec<ObjectId>,
2301 promised: &HashSet<ObjectId>,
2302) -> Result<()> {
2303 let alternates = AlternateSources::load(odb)?;
2304 let mut in_send: OidHashSet = send.iter().copied().collect();
2305 odb.primary()?
2306 .packed_objects()
2307 .for_each_removable_pack_object(&mut |oid| {
2308 if !promised.contains(oid) && !alternates.contains(oid) && in_send.insert(*oid) {
2309 send.push(*oid);
2310 }
2311 std::ops::ControlFlow::Continue(())
2312 })?;
2313 for (oid, _) in odb.enumerate_local_loose_objects()? {
2314 if !promised.contains(&oid) && !alternates.contains(&oid) && in_send.insert(oid) {
2315 send.push(oid);
2316 }
2317 }
2318 Ok(())
2319}
2320
2321fn pack_repack_inventory(pack_dir: &Path) -> crate::odb::RepackPackInventorySnapshot {
2322 let pack_paths = local_pack_paths(pack_dir);
2323 let kept_at_inventory = pack_paths
2324 .iter()
2325 .filter(|pack_path| pack_has_keep_marker(pack_dir, pack_path))
2326 .cloned()
2327 .collect();
2328 crate::odb::RepackPackInventorySnapshot {
2329 pack_paths,
2330 kept_at_inventory,
2331 }
2332}
2333
2334fn local_pack_paths(pack_dir: &Path) -> HashSet<PathBuf> {
2336 let Ok(entries) = fs::read_dir(pack_dir) else {
2337 return HashSet::new();
2338 };
2339 entries
2340 .filter_map(|e| e.ok())
2341 .map(|e| pack_dir.join(e.file_name()))
2342 .filter(|p| p.extension().is_some_and(|e| e == "pack"))
2343 .collect()
2344}
2345
2346fn count_pack_files(pack_dir: &Path) -> usize {
2348 let Ok(entries) = fs::read_dir(pack_dir) else {
2349 return 0;
2350 };
2351 let mut packs = HashSet::new();
2352 let mut kept = HashSet::new();
2353 for entry in entries.filter_map(|e| e.ok()) {
2354 let path = PathBuf::from(entry.file_name());
2355 let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
2356 continue;
2357 };
2358 if ext == "pack" {
2359 packs.insert(stem.to_owned());
2360 } else if ext == "keep" {
2361 kept.insert(stem.to_owned());
2362 }
2363 }
2364 packs.difference(&kept).count()
2365}
2366
2367fn packs_holding_available_promises(
2371 odb: &Odb,
2372 promised: &HashSet<ObjectId>,
2373) -> Result<HashSet<PathBuf>> {
2374 if promised.is_empty() {
2375 return Ok(HashSet::new());
2376 }
2377 let indexes = odb.local_pack_indexes()?;
2378 let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
2379 let mut retained = HashSet::new();
2380 for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
2381 for entry in idx.iter() {
2382 let oid = ObjectId::from_bytes(entry.oid())?;
2383 if promised.contains(&oid)
2384 && !promisor_indexes
2385 .iter()
2386 .any(|idx| idx.find_position(&oid).is_some())
2387 {
2388 retained.insert(idx.pack_path.clone());
2389 break;
2390 }
2391 }
2392 }
2393 Ok(retained)
2394}
2395
2396fn remove_superseded_pack_files_retaining(
2399 pack_dir: &Path,
2400 keep_pack: &Path,
2401 retained_packs: &HashSet<PathBuf>,
2402 inventory: &crate::odb::RepackPackInventorySnapshot,
2403) -> Result<usize> {
2404 let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
2405 let keep_stem = keep_pack
2406 .file_stem()
2407 .and_then(|s| s.to_str())
2408 .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
2409 if !pack_dir.is_dir() {
2410 return Ok(0);
2411 }
2412 let mut removed = 0usize;
2413 for entry in crate::git_dir::io_error::io_with_git_path(
2414 &git_dir,
2415 pack_dir,
2416 crate::git_dir::GitDirPathOperation::GcPack,
2417 fs::read_dir(pack_dir),
2418 )? {
2419 let entry = entry.map_err(|source| {
2420 crate::git_dir::io_error::map_git_dir_io(
2421 &git_dir,
2422 pack_dir,
2423 crate::git_dir::GitDirPathOperation::GcPack,
2424 source,
2425 )
2426 })?;
2427 let name = entry.file_name();
2428 let name_str = name.to_string_lossy();
2429 if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2430 continue;
2431 }
2432 if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2433 continue;
2434 }
2435 let path = entry.path();
2436 if path.is_file() {
2437 let pack_path = path.with_extension("pack");
2438 if inventory.kept_at_inventory.contains(&pack_path)
2439 || path.with_extension("promisor").is_file()
2440 || retained_packs.contains(&pack_path)
2441 || !inventory.pack_paths.contains(&pack_path)
2442 {
2443 continue;
2444 }
2445 crate::git_dir::io_error::io_with_git_path(
2446 &git_dir,
2447 &path,
2448 crate::git_dir::GitDirPathOperation::GcPack,
2449 fs::remove_file(&path),
2450 )?;
2451 removed += 1;
2452 }
2453 }
2454 Ok(removed)
2455}
2456
2457#[derive(Debug, Clone, Default, PartialEq, Eq)]
2459pub(crate) struct PromisorRewrite {
2460 pub(crate) pack_path: Option<PathBuf>,
2463 pub(crate) objects: usize,
2465 pub(crate) dropped: usize,
2467 pub(crate) removed_pack_files: usize,
2469}
2470
2471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2473pub(crate) enum PromisorRewriteWhen {
2474 Dropping,
2476 Always,
2478}
2479
2480pub(crate) fn rewrite_local_objects_without(
2519 repo: &Repository,
2520 omit: &OidHashSet,
2521 opts: &RepackOptions,
2522 when: PromisorRewriteWhen,
2523) -> Result<PromisorRewrite> {
2524 let odb = repo.odb();
2525 odb.require_files_primary("promisor rewrite")?;
2526 if omit.is_empty() && when == PromisorRewriteWhen::Dropping {
2527 return Ok(PromisorRewrite::default());
2528 }
2529 let objects_dir = odb.objects_dir().to_path_buf();
2530 let pack_dir = objects_dir.join("pack");
2531 odb.reprepare_local_packs();
2532 let snapshot: Vec<_> = odb
2533 .local_pack_indexes()?
2534 .into_iter()
2535 .filter(|idx| !pack_has_keep_marker(&pack_dir, &idx.pack_path))
2536 .collect();
2537
2538 let mut send = Vec::new();
2539 let mut seen: OidHashSet = OidHashSet::default();
2540 let mut dropped: OidHashSet = OidHashSet::default();
2541 let mut take = |oid: ObjectId| {
2542 if omit.contains(&oid) {
2543 dropped.insert(oid);
2544 } else if seen.insert(oid) {
2545 send.push(oid);
2546 }
2547 };
2548 for idx in &snapshot {
2549 for entry in idx.iter() {
2550 take(ObjectId::from_bytes(entry.oid())?);
2551 }
2552 }
2553 let loose = odb.enumerate_local_loose_objects()?;
2554 for (oid, _) in &loose {
2555 take(*oid);
2556 }
2557 let folded = loose.is_empty() && snapshot.len() <= 1 && snapshot.iter().all(|i| i.is_promisor);
2558 let nothing_to_do = match when {
2559 PromisorRewriteWhen::Dropping => dropped.is_empty(),
2560 PromisorRewriteWhen::Always => dropped.is_empty() && folded,
2561 };
2562 if nothing_to_do {
2563 return Ok(PromisorRewrite::default());
2564 }
2565
2566 let cfg = opts
2567 .merged_config
2568 .clone()
2569 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
2570 let pack_opts = opts
2571 .pack_build
2572 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
2573 let built = write_staged_repack_pack(
2574 odb,
2575 &pack_dir,
2576 &send,
2577 &HashMap::new(),
2578 &pack_opts,
2579 opts.threads,
2580 cfg.as_ref(),
2581 )?;
2582 clear_pack_midx_state(&pack_dir)?;
2583 let mut rewrite = PromisorRewrite {
2584 dropped: dropped.len(),
2585 ..PromisorRewrite::default()
2586 };
2587 if let Some(staging) = built.staging_path.as_ref() {
2588 let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
2589 let ingested = install_pack_path(
2590 staging,
2591 odb,
2592 &IngestPackOptions {
2593 threads: opts.threads,
2594 merged_config: cfg.clone(),
2595 environment: opts.environment.clone(),
2596 skip_post_index_verify: trusted_rows.is_some(),
2597 trusted_index_rows: trusted_rows,
2598 mark_promisor_pack: true,
2599 ..IngestPackOptions::default()
2600 },
2601 )?;
2602 rewrite.objects = ingested.object_ids.len();
2603 if let Some(pack_path) = &ingested.pack_path {
2604 if cfg
2605 .as_ref()
2606 .is_none_or(|c| c.pack_write_reverse_index_default())
2607 {
2608 write_pack_rev_sidecar_for_index_path(&pack_path.with_extension("idx"))?;
2609 }
2610 crate::index_pack::sync_installed_pack(pack_path)?;
2611 }
2612 rewrite.pack_path = ingested.pack_path;
2613 }
2614
2615 odb.invalidate_packs();
2617 let new_stem = rewrite
2618 .pack_path
2619 .as_deref()
2620 .and_then(Path::file_stem)
2621 .map(|s| s.to_os_string());
2622 let stems: HashSet<std::ffi::OsString> = snapshot
2623 .iter()
2624 .filter_map(|idx| idx.pack_path.file_stem().map(|s| s.to_os_string()))
2625 .filter(|stem| Some(stem) != new_stem.as_ref())
2626 .collect();
2627 drop(snapshot);
2628 rewrite.removed_pack_files = remove_pack_files_with_stems(&pack_dir, &stems)?;
2629 for oid in &dropped {
2630 odb.remove_local_loose(oid)?;
2631 }
2632 prune_packed_objects(&objects_dir, PrunePackedOptions::default())?;
2633 odb.invalidate_packs();
2634 Ok(rewrite)
2635}
2636
2637fn remove_pack_files_with_stems(
2641 pack_dir: &Path,
2642 stems: &HashSet<std::ffi::OsString>,
2643) -> Result<usize> {
2644 if stems.is_empty() {
2645 return Ok(0);
2646 }
2647 let mut removed = 0usize;
2648 for entry in fs::read_dir(pack_dir)?.filter_map(|e| e.ok()) {
2649 let path = entry.path();
2650 let Some(stem) = path.file_stem().filter(|stem| stems.contains(*stem)) else {
2651 continue;
2652 };
2653 if !path.is_file() || pack_dir.join(stem).with_extension("keep").is_file() {
2654 continue;
2655 }
2656 fs::remove_file(&path)?;
2657 removed += 1;
2658 }
2659 Ok(removed)
2660}
2661
2662fn pack_has_keep_marker(pack_dir: &Path, pack_path: &Path) -> bool {
2663 let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
2664 return false;
2665 };
2666 pack_dir.join(format!("{stem}.keep")).is_file()
2667}
2668
2669pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2678 let (path, refs_packed, refs_pruned) =
2679 crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2680 Ok(PackRefsReport {
2681 path,
2682 refs_packed,
2683 refs_pruned,
2684 })
2685}
2686
2687impl Repository {
2688 pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2694 write_commit_graph(self, opts)
2695 }
2696
2697 pub fn build_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphBuild> {
2703 build_commit_graph(self, opts)
2704 }
2705
2706 pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2712 write_midx(self, opts)
2713 }
2714}
2715
2716pub mod fsck {
2718 pub use crate::fsck_msg_policy::{
2719 FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2720 };
2721 pub use crate::fsck_standalone::{fsck_object, fsck_object_with, FsckError, FsckObjectOptions};
2722}
2723
2724pub mod repository_fsck {
2726 pub use crate::fsck::{
2727 format_fsck_line, fsck, issue_msg_id, issue_severity, issue_visible_by_default,
2728 reachability_roots, FsckDiagnostic, FsckIssue, FsckOptions, FsckReport, FsckSeverity,
2729 FsckSummary, MissingObjectReferrer,
2730 };
2731}
2732
2733#[cfg(test)]
2734pub(crate) mod gc_test_hooks {
2735 use std::cell::RefCell;
2736
2737 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
2739 pub enum GcPausePoint {
2740 AfterInventoryBeforeRoots,
2742 AfterRootsBeforeDropAll,
2744 }
2745
2746 type GcHook = (GcPausePoint, Box<dyn FnOnce() + Send>);
2747
2748 thread_local! {
2749 static GC_HOOK: RefCell<Option<GcHook>> = const { RefCell::new(None) };
2750 }
2751
2752 pub fn on_next_gc(point: GcPausePoint, hook: impl FnOnce() + Send + 'static) {
2754 GC_HOOK.with(|slot| *slot.borrow_mut() = Some((point, Box::new(hook))));
2755 }
2756
2757 pub(crate) fn run(point: GcPausePoint) {
2758 let hook = GC_HOOK.with(|slot| {
2759 let mut slot = slot.borrow_mut();
2760 match slot.as_ref() {
2761 Some((at, _)) if *at == point => slot.take().map(|(_, hook)| hook),
2762 _ => None,
2763 }
2764 });
2765 if let Some(hook) = hook {
2766 hook();
2767 }
2768 }
2769}
2770
2771#[cfg(test)]
2772mod tests {
2773 #![allow(clippy::unwrap_used, clippy::expect_used)]
2774
2775 use super::*;
2776 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2777 use crate::index_pack::install_pack_bytes;
2778 use crate::porcelain::add::stage_for_commit;
2779 use crate::porcelain::commit::{create_commit, CommitRequest};
2780 use crate::progress::{NeverCancel, NullProgress};
2781 use crate::repo::InitOptions;
2782 use filetime::FileTime;
2783 use std::time::SystemTime;
2784 use tempfile::TempDir;
2785
2786 fn pack_into(repo: &Repository, oids: &[ObjectId], keep: bool) -> PathBuf {
2789 let pack = crate::pack_objects::build_pack_from_send_list(
2790 repo.odb(),
2791 oids,
2792 &HashSet::new(),
2793 &PackBuildOptions::default(),
2794 &HashMap::new(),
2795 )
2796 .unwrap();
2797 install_pack_bytes(
2798 pack,
2799 repo.odb(),
2800 &IngestPackOptions {
2801 keep_message: keep.then(|| "pinned".to_owned()),
2802 ..IngestPackOptions::default()
2803 },
2804 )
2805 .unwrap()
2806 .pack_path
2807 .unwrap()
2808 }
2809
2810 #[test]
2811 fn promisor_rewrite_drops_only_the_omitted_objects() {
2812 let dir = TempDir::new().unwrap();
2813 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2814 let odb = repo.odb();
2815 let big = odb.write(ObjectKind::Blob, b"big blob").unwrap();
2816 let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2817 let kept = odb.write(ObjectKind::Blob, b"kept").unwrap();
2818 let unreachable = odb.write(ObjectKind::Blob, b"unreachable loose").unwrap();
2819 let mut body = b"100644 big\0".to_vec();
2820 body.extend_from_slice(big.as_bytes());
2821 let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2822 let old_pack = pack_into(&repo, &[packed, big, tree], false);
2823 let kept_pack = pack_into(&repo, &[kept], true);
2824 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2825 odb.write_loose_materialize(ObjectKind::Blob, b"big blob")
2827 .unwrap();
2828
2829 let omit: OidHashSet = [big, kept].into_iter().collect();
2830 let rewrite = rewrite_local_objects_without(
2831 &repo,
2832 &omit,
2833 &RepackOptions::default(),
2834 PromisorRewriteWhen::Dropping,
2835 )
2836 .unwrap();
2837 assert_eq!(rewrite.dropped, 1, "the kept copy is not ours to drop");
2838 assert_eq!(rewrite.objects, 3, "tree + packed + unreachable");
2839 let new_pack = rewrite.pack_path.expect("new pack");
2840 assert!(new_pack.with_extension("promisor").is_file());
2841 repo.odb().reprepare_local_packs();
2844 let indexes = repo.odb().local_pack_indexes().unwrap();
2845 let new_idx = indexes
2846 .iter()
2847 .find(|i| i.pack_path == new_pack)
2848 .expect("listed");
2849 for oid in [tree, packed, unreachable] {
2850 assert!(new_idx.find_position(&oid).is_some(), "{oid}");
2851 }
2852 assert!(!old_pack.exists());
2853 assert!(kept_pack.is_file() && kept_pack.with_extension("keep").is_file());
2854 assert!(rewrite.removed_pack_files >= 2, "old .pack and .idx");
2855
2856 let repo =
2857 Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2858 let odb = repo.odb();
2859 assert!(!odb.exists_stored(&big));
2860 assert!(odb.local_loose_file_size(&big).is_none());
2861 for oid in [packed, kept, unreachable, tree] {
2862 assert!(odb.exists_stored(&oid), "{oid} kept");
2863 }
2864 assert!(odb.enumerate_local_loose_objects().unwrap().is_empty());
2865 }
2866
2867 #[test]
2868 fn promisor_rewrite_without_anything_to_drop_changes_nothing() {
2869 let dir = TempDir::new().unwrap();
2870 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2871 let blob = repo.odb().write(ObjectKind::Blob, b"x").unwrap();
2872 let absent = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
2873 for omit in [OidHashSet::default(), [absent].into_iter().collect()] {
2874 let rewrite = rewrite_local_objects_without(
2875 &repo,
2876 &omit,
2877 &RepackOptions::default(),
2878 PromisorRewriteWhen::Dropping,
2879 )
2880 .unwrap();
2881 assert_eq!(rewrite, PromisorRewrite::default());
2882 }
2883 assert!(repo.odb().local_loose_file_size(&blob).is_some());
2884 }
2885
2886 #[test]
2887 fn promisor_rewrite_always_folds_until_one_promisor_pack_is_left() {
2888 let dir = TempDir::new().unwrap();
2889 let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2890 let odb = repo.odb();
2891 let pushed = odb.write(ObjectKind::Blob, b"pushed").unwrap();
2892 let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2893 let mut body = b"100644 packed\0".to_vec();
2894 body.extend_from_slice(packed.as_bytes());
2895 let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2896 let tree_pack = pack_into(&repo, &[tree], false);
2899 fs::write(tree_pack.with_extension("promisor"), b"").unwrap();
2900 let blob_pack = pack_into(&repo, &[packed], false);
2901 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2902 odb.write_loose_materialize(ObjectKind::Blob, b"pushed")
2903 .unwrap();
2904 let fold = |repo: &Repository| {
2905 rewrite_local_objects_without(
2906 repo,
2907 &OidHashSet::default(),
2908 &RepackOptions::default(),
2909 PromisorRewriteWhen::Always,
2910 )
2911 .unwrap()
2912 };
2913 let first = fold(&repo);
2914 assert_eq!(first.dropped, 0);
2915 assert_eq!(first.objects, 3);
2916 let pack = first.pack_path.expect("folded pack");
2917 assert!(pack.with_extension("promisor").is_file());
2918 assert!(!tree_pack.exists() && !blob_pack.exists());
2919 let repo =
2920 Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2921 assert!(repo
2922 .odb()
2923 .enumerate_local_loose_objects()
2924 .unwrap()
2925 .is_empty());
2926 let indexes = repo.odb().local_pack_indexes().unwrap();
2927 assert_eq!(indexes.len(), 1);
2928 for oid in [pushed, packed, tree] {
2929 assert!(indexes[0].find_position(&oid).is_some(), "{oid}");
2930 }
2931 assert_eq!(fold(&repo), PromisorRewrite::default());
2933 assert!(pack.is_file());
2934 }
2935
2936 #[test]
2937 fn pack_removal_spares_unlisted_and_newly_kept_packs() {
2938 let dir = TempDir::new().unwrap();
2939 for name in [
2940 "pack-a.pack",
2941 "pack-a.idx",
2942 "pack-a.rev",
2943 "pack-b.pack",
2944 "pack-b.idx",
2945 "pack-c.pack",
2946 "pack-c.keep",
2947 ] {
2948 fs::write(dir.path().join(name), b"").unwrap();
2949 }
2950 let stems: HashSet<std::ffi::OsString> =
2951 ["pack-a", "pack-c"].into_iter().map(Into::into).collect();
2952 assert_eq!(remove_pack_files_with_stems(dir.path(), &stems).unwrap(), 3);
2953 let mut left: Vec<String> = fs::read_dir(dir.path())
2954 .unwrap()
2955 .flatten()
2956 .map(|e| e.file_name().to_string_lossy().into_owned())
2957 .collect();
2958 left.sort();
2959 assert_eq!(
2960 left,
2961 ["pack-b.idx", "pack-b.pack", "pack-c.keep", "pack-c.pack"]
2962 );
2963 }
2964
2965 #[cfg_attr(
2966 not(feature = "full-tests"),
2967 ignore = "starts system git or is heavy; run with --features full-tests"
2968 )]
2969 #[test]
2970 fn gc_auto_skip_single_pack_without_loose() {
2971 let dir = TempDir::new().unwrap();
2972 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2973 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2974 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2975 let ident = "Test <t@example.com> 1 +0000".to_string();
2976 create_commit(
2977 &repo,
2978 &CommitRequest {
2979 message: "init".into(),
2980 author: Some(ident.parse().expect("signature")),
2981 committer: Some(ident.parse().expect("signature")),
2982 allow_empty: false,
2983 sign_override: None,
2984 amend: false,
2985 assume_worktree_synced: false,
2986 skip_pre_commit_tree_match: false,
2987 staged_change_count: None,
2988 ..Default::default()
2989 },
2990 &mut NullProgress,
2991 &NeverCancel,
2992 )
2993 .expect("commit");
2994 git_cmd(dir.path(), &["repack", "-a", "-d"]);
2995 assert!(should_auto_skip_repack(0, 1, false, true, None));
2996 assert!(!should_auto_skip_repack(3, 1, false, true, None));
2997 assert!(!should_auto_skip_repack(0, 2, false, true, None));
2998 assert!(!should_auto_skip_repack(0, 2, false, false, None));
2999 }
3000
3001 #[test]
3002 fn count_pack_files_ignores_keep_markers() {
3003 let dir = TempDir::new().unwrap();
3004 let pack_dir = dir.path().join("pack");
3005 fs::create_dir_all(&pack_dir).unwrap();
3006 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
3007 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
3008 fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
3009 assert_eq!(count_pack_files(&pack_dir), 1);
3010 }
3011
3012 fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
3013 let dir = TempDir::new().unwrap();
3014 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3015 let config_path = repo.git_dir().join("config");
3016 let mut config = fs::read_to_string(&config_path).unwrap();
3017 config.push_str(snippet);
3018 fs::write(&config_path, config).unwrap();
3019 repo.reload_config().expect("reload config");
3020 (dir, repo)
3021 }
3022
3023 fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
3024 let dir = repo.git_dir().join("objects").join(fanout);
3025 fs::create_dir_all(&dir).unwrap();
3026 for name in names {
3027 fs::write(dir.join(name), b"").unwrap();
3028 }
3029 }
3030
3031 const NAME_A: &str = "00000000000000000000000000000000000001";
3032 const NAME_B: &str = "00000000000000000000000000000000000002";
3033 const NAME_C: &str = "00000000000000000000000000000000000003";
3034
3035 #[test]
3036 fn auto_gc_needed_samples_one_fanout_directory() {
3037 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
3039 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3040 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3041 fake_loose(&repo, "18", &[NAME_C]);
3042 fake_loose(
3043 &repo,
3044 "17",
3045 &["tmp_obj_123", "0000000000000000000000000000000000000z"],
3046 );
3047 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3048 fake_loose(&repo, "17", &[NAME_C]);
3049 assert_eq!(
3050 auto_gc_needed(&repo).unwrap(),
3051 Some(AutoGcReason::TooManyLoose {
3052 estimate: 3 * 256,
3053 limit: 300
3054 })
3055 );
3056 }
3057
3058 #[test]
3059 fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
3060 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
3061 fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
3062 let pack_dir = repo.git_dir().join("objects/pack");
3063 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
3064 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
3065 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3066 assert_eq!(
3067 gc_auto(&repo, &GcOptions::default()).unwrap(),
3068 AutoGcOutcome::NotNeeded
3069 );
3070 }
3071
3072 #[test]
3073 fn auto_gc_needed_counts_packs_without_keep_markers() {
3074 let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
3075 let pack_dir = repo.git_dir().join("objects/pack");
3076 for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
3077 fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
3078 }
3079 assert_eq!(
3080 auto_gc_needed(&repo).unwrap(),
3081 Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
3082 );
3083 fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
3084 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3085 }
3086
3087 fn config_snippet(text: &str) -> ConfigSet {
3088 let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
3089 let mut set = ConfigSet::new();
3090 set.merge(&file);
3091 set
3092 }
3093
3094 #[test]
3095 fn prune_expire_now_and_all_have_no_grace_window() {
3096 let now = 1_700_000_000;
3097 for spec in ["now", "all", "NOW"] {
3098 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3099 assert!(matches!(
3100 gc_loose_prune_policy(&cfg, now).unwrap(),
3101 GcLoosePrunePolicy::Enabled {
3102 keep_newer_than: None
3103 }
3104 ));
3105 }
3106 for spec in ["never", "false"] {
3107 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3108 assert!(matches!(
3109 gc_loose_prune_policy(&cfg, now).unwrap(),
3110 GcLoosePrunePolicy::Disabled
3111 ));
3112 }
3113 let cfg = ConfigSet::new();
3114 assert!(matches!(
3115 gc_loose_prune_policy(&cfg, now).unwrap(),
3116 GcLoosePrunePolicy::Enabled {
3117 keep_newer_than: Some(_)
3118 }
3119 ));
3120 }
3121
3122 #[test]
3123 fn gc_lock_is_exclusive_and_released_on_drop() {
3124 let (_dir, repo) = repo_with_config("");
3125 let now = OffsetDateTime::now_utc().unix_timestamp();
3126 let pid_path = repo.git_dir().join("gc.pid");
3127 let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
3128 panic!("first acquire must succeed");
3129 };
3130 let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
3131 assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
3132 assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
3133 match GcLock::try_acquire(&repo, now).unwrap() {
3134 GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
3135 GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
3136 }
3137 assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
3138 assert!(matches!(
3139 gc(&repo, &GcOptions::default()),
3140 Err(Error::MaintenanceRunning { .. })
3141 ));
3142 assert!(!repo.git_dir().join("gc.pid.lock").exists());
3143 drop(lock);
3144 assert!(!pid_path.exists());
3145 assert_eq!(GcLock::current_holder(&repo, now), None);
3146 assert!(matches!(
3147 GcLock::try_acquire(&repo, now).unwrap(),
3148 GcLockAttempt::Acquired(_)
3149 ));
3150 }
3151
3152 #[test]
3153 fn gc_lock_takes_over_stale_entries() {
3154 let (_dir, repo) = repo_with_config("");
3155 let now = OffsetDateTime::now_utc().unix_timestamp();
3156 let pid_path = repo.git_dir().join("gc.pid");
3157 let host = local_host_name();
3158
3159 fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
3161 assert!(matches!(
3162 GcLock::try_acquire(&repo, now).unwrap(),
3163 GcLockAttempt::Acquired(_)
3164 ));
3165
3166 fs::write(&pid_path, "4242 some-other-host").unwrap();
3168 assert!(matches!(
3169 GcLock::try_acquire(&repo, now).unwrap(),
3170 GcLockAttempt::Held(GcLockHolder {
3171 pid: Some(4242),
3172 ..
3173 })
3174 ));
3175
3176 let later = now + time::Duration::hours(13).whole_seconds();
3178 assert!(matches!(
3179 GcLock::try_acquire(&repo, later).unwrap(),
3180 GcLockAttempt::Acquired(_)
3181 ));
3182 }
3183
3184 #[test]
3185 fn gc_auto_reports_held_lock_without_running() {
3186 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3187 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3188 fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
3189 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3190 assert!(
3191 matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
3192 "{outcome:?}"
3193 );
3194 assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
3195 }
3196
3197 #[cfg(unix)]
3198 #[test]
3199 fn gc_auto_honors_failing_pre_auto_gc_hook() {
3200 use std::os::unix::fs::PermissionsExt as _;
3201 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3202 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3203 let hook = repo.git_dir().join("hooks/pre-auto-gc");
3204 fs::create_dir_all(hook.parent().unwrap()).unwrap();
3205 fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
3206 fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
3207 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3208 assert!(
3209 matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
3210 "{outcome:?}"
3211 );
3212 assert!(!repo.git_dir().join("gc.pid").exists());
3213 }
3214
3215 fn git_cmd(dir: &Path, args: &[&str]) {
3216 let status = std::process::Command::new("git")
3217 .current_dir(dir)
3218 .args(args)
3219 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3220 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3221 .status()
3222 .expect("git");
3223 assert!(status.success(), "git {args:?}");
3224 }
3225
3226 fn assert_fsck_clean(repo: &Repository) {
3227 let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
3228 let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
3229 assert!(
3230 report.ok(&repo.config().expect("config"), &opts),
3231 "{:?}",
3232 report.issues
3233 );
3234 }
3235
3236 #[test]
3237 fn fsck_clean_repo_ok() {
3238 let dir = TempDir::new().unwrap();
3239 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3240 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3241 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3242 let ident = "Test <t@example.com> 1 +0000".to_string();
3243 create_commit(
3244 &repo,
3245 &CommitRequest {
3246 message: "init".into(),
3247 author: Some(ident.parse().expect("signature")),
3248 committer: Some(ident.parse().expect("signature")),
3249 allow_empty: false,
3250 sign_override: None,
3251 amend: false,
3252 assume_worktree_synced: false,
3253 skip_pre_commit_tree_match: false,
3254 staged_change_count: None,
3255 ..Default::default()
3256 },
3257 &mut NullProgress,
3258 &NeverCancel,
3259 )
3260 .expect("commit");
3261 assert_fsck_clean(&repo);
3262 }
3263
3264 #[test]
3265 fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
3266 let dir = TempDir::new().unwrap();
3267 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3268 let wt = repo.work_tree().unwrap();
3269 std::fs::write(wt.join("base"), b"v1").unwrap();
3270 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3271 let ident = "Test <t@example.com> 1 +0000".to_string();
3272 create_commit(
3273 &repo,
3274 &CommitRequest {
3275 message: "base".into(),
3276 author: Some(ident.parse().expect("signature")),
3277 committer: Some(ident.parse().expect("signature")),
3278 allow_empty: false,
3279 sign_override: None,
3280 amend: false,
3281 assume_worktree_synced: false,
3282 skip_pre_commit_tree_match: false,
3283 staged_change_count: None,
3284 ..Default::default()
3285 },
3286 &mut NullProgress,
3287 &NeverCancel,
3288 )
3289 .expect("commit");
3290 repack_all(
3291 &repo,
3292 &RepackOptions {
3293 keep_unreachable: false,
3294 ..RepackOptions::default()
3295 },
3296 )
3297 .expect("repack base");
3298 let pack_dir = repo.git_dir().join("objects/pack");
3299 assert_eq!(count_pack_files(&pack_dir), 1);
3300
3301 std::fs::write(wt.join("child"), b"v2").unwrap();
3302 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
3303 create_commit(
3304 &repo,
3305 &CommitRequest {
3306 message: "child".into(),
3307 author: Some(ident.parse().expect("signature")),
3308 committer: Some(ident.parse().expect("signature")),
3309 allow_empty: false,
3310 sign_override: None,
3311 amend: false,
3312 assume_worktree_synced: false,
3313 skip_pre_commit_tree_match: false,
3314 staged_change_count: None,
3315 ..Default::default()
3316 },
3317 &mut NullProgress,
3318 &NeverCancel,
3319 )
3320 .expect("child commit");
3321 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3322
3323 let packs_before = count_pack_files(&pack_dir);
3324 repack_all(
3325 &repo,
3326 &RepackOptions {
3327 incremental: true,
3328 write_bitmap: true,
3329 ..RepackOptions::default()
3330 },
3331 )
3332 .expect("incremental repack with bitmap must not fail after installing a pack");
3333
3334 assert!(
3335 count_pack_files(&pack_dir) > packs_before,
3336 "incremental repack should add a pack"
3337 );
3338 let midx = pack_dir.join("multi-pack-index");
3339 assert!(
3340 midx.is_file(),
3341 "expected multi-pack-index after bitmap repack"
3342 );
3343 let midx_bytes = fs::read(&midx).expect("read midx");
3344 assert_eq!(
3345 midx_bytes.get(4),
3346 Some(&1),
3347 "MIDX on-disk version must be v1 for system git compatibility"
3348 );
3349 let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
3350 .unwrap()
3351 .filter_map(|e| e.ok())
3352 .map(|e| e.path())
3353 .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
3354 .collect();
3355 assert!(
3356 !bitmaps.is_empty(),
3357 "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
3358 );
3359
3360 assert_fsck_clean(&repo);
3361 }
3362
3363 #[test]
3364 fn incremental_repack_adds_pack_without_removing_base() {
3365 let dir = TempDir::new().unwrap();
3366 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3367 let wt = repo.work_tree().unwrap();
3368 std::fs::write(wt.join("base"), b"v1").unwrap();
3369 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3370 let ident = "Test <t@example.com> 1 +0000".to_string();
3371 create_commit(
3372 &repo,
3373 &CommitRequest {
3374 message: "base".into(),
3375 author: Some(ident.parse().expect("signature")),
3376 committer: Some(ident.parse().expect("signature")),
3377 allow_empty: false,
3378 sign_override: None,
3379 amend: false,
3380 assume_worktree_synced: false,
3381 skip_pre_commit_tree_match: false,
3382 staged_change_count: None,
3383 ..Default::default()
3384 },
3385 &mut NullProgress,
3386 &NeverCancel,
3387 )
3388 .expect("commit");
3389 repack_all(
3390 &repo,
3391 &RepackOptions {
3392 keep_unreachable: false,
3393 ..RepackOptions::default()
3394 },
3395 )
3396 .expect("repack");
3397 let pack_dir = repo.git_dir().join("objects/pack");
3398 let packs_before = count_pack_files(&pack_dir);
3399 assert_eq!(packs_before, 1);
3400 for i in 0..5 {
3401 create_commit(
3402 &repo,
3403 &CommitRequest {
3404 message: format!("empty-{i}"),
3405 author: Some(ident.parse().expect("signature")),
3406 committer: Some(ident.parse().expect("signature")),
3407 allow_empty: true,
3408 sign_override: None,
3409 amend: false,
3410 assume_worktree_synced: false,
3411 skip_pre_commit_tree_match: false,
3412 staged_change_count: None,
3413 ..Default::default()
3414 },
3415 &mut NullProgress,
3416 &NeverCancel,
3417 )
3418 .expect("empty");
3419 }
3420 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3421 repack_all(
3422 &repo,
3423 &RepackOptions {
3424 incremental: true,
3425 write_bitmap: false,
3426 ..RepackOptions::default()
3427 },
3428 )
3429 .expect("incremental repack");
3430 assert!(
3431 count_pack_files(&pack_dir) >= 2,
3432 "incremental repack should add a pack, not replace the base"
3433 );
3434 }
3435
3436 #[test]
3437 fn gc_full_run_records_per_phase_timings() {
3438 let dir = TempDir::new().unwrap();
3439 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3440 let wt = repo.work_tree().unwrap();
3441 for i in 0..5 {
3442 std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3443 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3444 let ident = "Test <t@example.com> 1 +0000".to_string();
3445 create_commit(
3446 &repo,
3447 &CommitRequest {
3448 message: format!("c{i}"),
3449 author: Some(ident.parse().expect("signature")),
3450 committer: Some(ident.parse().expect("signature")),
3451 allow_empty: false,
3452 sign_override: None,
3453 amend: false,
3454 assume_worktree_synced: false,
3455 skip_pre_commit_tree_match: false,
3456 staged_change_count: None,
3457 ..Default::default()
3458 },
3459 &mut NullProgress,
3460 &NeverCancel,
3461 )
3462 .expect("commit");
3463 }
3464 let stats = gc(
3465 &repo,
3466 &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
3467 )
3468 .expect("gc");
3469 let t = stats.phase_timings;
3470 assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
3471 assert!(
3472 t.repack_ms > 0,
3473 "repack phase must be timed separately from full gc"
3474 );
3475 assert!(stats.pack_refs.is_some());
3476 assert!(stats.commit_graph.is_some());
3477 assert_eq!(
3478 t.prune_ms, stats.repack.unreachable_prune_ms,
3479 "prune_ms must track unreachable loose prune inside repack, not repack_ms"
3480 );
3481 assert!(
3484 t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
3485 "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
3486 );
3487 assert!(stats.prune_ran, "full gc should run unreachable prune");
3488 }
3489
3490 #[cfg_attr(
3491 not(feature = "full-tests"),
3492 ignore = "starts system git or is heavy; run with --features full-tests"
3493 )]
3494 #[test]
3495 fn full_repack_keeps_migrated_pack_before_refs_updated() {
3496 use crate::receive_quarantine::ReceiveQuarantine;
3497
3498 let tmp = tempfile::tempdir().expect("tempdir");
3499 let remote = tmp.path().join("remote.git");
3500 let wt = tmp.path().join("wt");
3501 std::fs::create_dir_all(&wt).unwrap();
3502 std::fs::create_dir_all(&remote).unwrap();
3503 let git = |dir: &Path, args: &[&str]| {
3504 assert!(std::process::Command::new("git")
3505 .current_dir(dir)
3506 .args(args)
3507 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3508 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3509 .status()
3510 .expect("git")
3511 .success());
3512 };
3513 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3514 git(&wt, &["init", "-q", "-b", "main"]);
3515 std::fs::write(wt.join("payload.txt"), b"payload\n").unwrap();
3516 git(&wt, &["add", "payload.txt"]);
3517 git(&wt, &["commit", "-qm", "base"]);
3518 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3519 git(&remote, &["repack", "-a", "-d"]);
3520 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3521 .expect("open remote");
3522 let pack_dir = repo.git_dir().join("objects/pack");
3523 let base_pack = super::local_pack_paths(&pack_dir)
3524 .into_iter()
3525 .next()
3526 .expect("base pack");
3527 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3528 let q_root = PathBuf::from(
3529 q.hook_env()
3530 .into_iter()
3531 .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3532 .map(|(_, v)| v)
3533 .expect("quarantine path"),
3534 );
3535 let q_pack_dir = q_root.join("pack");
3536 fs::create_dir_all(&q_pack_dir).unwrap();
3537 for ext in ["pack", "idx", "rev"] {
3538 let src = base_pack.with_extension(ext);
3539 if src.is_file() {
3540 fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3541 }
3542 }
3543 let guards = q.migrate().expect("migrate");
3544 assert!(
3545 super::local_pack_paths(&pack_dir).len() >= 2,
3546 "migrated push pack must be visible before gc inventories packs"
3547 );
3548 repack_all(
3549 &repo,
3550 &RepackOptions {
3551 auto_skip: false,
3552 write_bitmap: false,
3553 ..RepackOptions::default()
3554 },
3555 )
3556 .expect("repack");
3557 assert!(
3558 pack_dir.join("pack-incoming.pack").is_file(),
3559 "incoming pack must survive full repack while .keep guards are held"
3560 );
3561 drop(guards);
3562 }
3563
3564 #[test]
3565 fn full_repack_drops_unprotected_migrated_pack_before_refs_updated() {
3566 use crate::receive_quarantine::ReceiveQuarantine;
3567
3568 let tmp = tempfile::tempdir().expect("tempdir");
3569 let remote = tmp.path().join("remote.git");
3570 let wt = tmp.path().join("wt");
3571 std::fs::create_dir_all(&wt).unwrap();
3572 std::fs::create_dir_all(&remote).unwrap();
3573 let git = |dir: &Path, args: &[&str]| {
3574 assert!(std::process::Command::new("git")
3575 .current_dir(dir)
3576 .args(args)
3577 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3578 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3579 .status()
3580 .expect("git")
3581 .success());
3582 };
3583 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3584 git(&wt, &["init", "-q", "-b", "main"]);
3585 git(&wt, &["commit", "--allow-empty", "-qm", "base"]);
3586 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3587 git(&remote, &["repack", "-a", "-d"]);
3588 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3589 .expect("open");
3590 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3591 let orphan = q
3592 .odb()
3593 .write(crate::objects::ObjectKind::Blob, b"orphan-bytes")
3594 .expect("write in quarantine");
3595 drop(q.migrate().expect("migrate"));
3596 repack_all(
3597 &repo,
3598 &RepackOptions {
3599 auto_skip: false,
3600 write_bitmap: false,
3601 keep_unreachable: false,
3602 ..RepackOptions::default()
3603 },
3604 )
3605 .expect("repack");
3606 assert!(
3607 !repo.odb().exists(&orphan),
3608 "without .keep protection gc may drop an unreachable migrated loose object"
3609 );
3610 }
3611
3612 #[cfg_attr(
3613 not(feature = "full-tests"),
3614 ignore = "starts system git or is heavy; run with --features full-tests"
3615 )]
3616 #[test]
3617 fn repack_retains_inventory_kept_pack_after_ref_publish_and_guard_drop() {
3618 use crate::receive_quarantine::ReceiveQuarantine;
3619 use std::process::Command;
3620 use std::sync::{Arc, Mutex};
3621
3622 let tmp = tempfile::tempdir().expect("tempdir");
3623 let remote = tmp.path().join("remote.git");
3624 let wt = tmp.path().join("wt");
3625 std::fs::create_dir_all(&wt).unwrap();
3626 std::fs::create_dir_all(&remote).unwrap();
3627 let git = |dir: &Path, args: &[&str]| {
3628 assert!(Command::new("git")
3629 .current_dir(dir)
3630 .args(args)
3631 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3632 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3633 .status()
3634 .expect("git")
3635 .success());
3636 };
3637 git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3638 git(&wt, &["init", "-q", "-b", "main"]);
3639 std::fs::write(wt.join("payload.txt"), b"v1\n").unwrap();
3640 git(&wt, &["add", "payload.txt"]);
3641 git(&wt, &["commit", "-qm", "base"]);
3642 git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3643 git(&remote, &["repack", "-a", "-d"]);
3644 std::fs::write(wt.join("payload.txt"), b"v2\n").unwrap();
3645 git(&wt, &["add", "payload.txt"]);
3646 git(&wt, &["commit", "-qm", "incoming"]);
3647 git(&wt, &["repack", "-a", "-d"]);
3648 let incoming_hex = String::from_utf8(
3649 Command::new("git")
3650 .current_dir(&wt)
3651 .args(["rev-parse", "HEAD"])
3652 .output()
3653 .expect("rev-parse")
3654 .stdout,
3655 )
3656 .expect("utf8")
3657 .trim()
3658 .to_owned();
3659 let incoming = ObjectId::from_hex(&incoming_hex).expect("incoming tip");
3660
3661 let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3662 .expect("open remote");
3663 let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3664 let q_root = PathBuf::from(
3665 q.hook_env()
3666 .into_iter()
3667 .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3668 .map(|(_, v)| v)
3669 .expect("quarantine path"),
3670 );
3671 let q_pack_dir = q_root.join("pack");
3672 fs::create_dir_all(&q_pack_dir).unwrap();
3673 let wt_pack_dir = wt.join(".git/objects/pack");
3674 let wt_pack = fs::read_dir(&wt_pack_dir)
3675 .expect("wt pack dir")
3676 .filter_map(|e| e.ok())
3677 .map(|e| e.path())
3678 .find(|p| p.extension().is_some_and(|ext| ext == "pack"))
3679 .expect("wt repack pack");
3680 for ext in ["pack", "idx", "rev"] {
3681 let src = wt_pack.with_extension(ext);
3682 if src.is_file() {
3683 fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3684 }
3685 }
3686 assert!(
3687 q_pack_dir.join("pack-incoming.pack").is_file(),
3688 "incoming pack sidecar"
3689 );
3690
3691 let pack_dir = repo.git_dir().join("objects/pack");
3692 let incoming_keep = pack_dir.join("pack-incoming.keep");
3693 let guards_slot = Arc::new(Mutex::new(None));
3694 let guards = q.migrate().expect("migrate");
3695 *guards_slot.lock().expect("lock") = Some(guards);
3696 assert!(incoming_keep.is_file());
3697
3698 let remote_for_hook = remote.clone();
3699 let incoming_for_hook = incoming_hex.clone();
3700 let incoming_keep_for_hook = incoming_keep.clone();
3701 let guards_for_hook = Arc::clone(&guards_slot);
3702 super::gc_test_hooks::on_next_gc(
3703 super::gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll,
3704 move || {
3705 assert!(Command::new("git")
3706 .current_dir(&remote_for_hook)
3707 .args(["update-ref", "refs/heads/main", &incoming_for_hook,])
3708 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3709 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3710 .status()
3711 .expect("update-ref")
3712 .success());
3713 drop(guards_for_hook.lock().expect("lock").take());
3714 assert!(
3715 !incoming_keep_for_hook.exists(),
3716 "receive path drops live keep markers after publication"
3717 );
3718 },
3719 );
3720
3721 repack_all(
3722 &repo,
3723 &RepackOptions {
3724 auto_skip: false,
3725 write_bitmap: false,
3726 ..RepackOptions::default()
3727 },
3728 )
3729 .expect("repack");
3730
3731 assert!(
3732 repo.odb().exists(&incoming),
3733 "inventory-time keep must protect the pack after live markers are dropped"
3734 );
3735 assert!(Command::new("git")
3736 .current_dir(&remote)
3737 .args(["fsck", "--strict"])
3738 .env("GIT_CONFIG_GLOBAL", "/dev/null")
3739 .env("GIT_CONFIG_SYSTEM", "/dev/null")
3740 .status()
3741 .expect("fsck")
3742 .success());
3743 }
3744
3745 #[test]
3746 fn pack_install_stage_name_matches_only_grit_staging_dirs() {
3747 assert!(is_grit_pack_install_stage_name(
3748 ".pack-deadbeef-install-1_0"
3749 ));
3750 assert!(!is_grit_pack_install_stage_name(".notes-install-backup"));
3751 assert!(!is_grit_pack_install_stage_name(".cache-install-state"));
3752 assert!(is_grit_pack_install_stage_name(
3753 ".pack-deadbeef-install-stale-file"
3754 ));
3755 }
3756
3757 #[test]
3758 fn gc_prunes_stale_tmp_pack_and_install_dirs() {
3759 let (_dir, repo) = repo_with_config("");
3760 let pack_dir = repo.git_dir().join("objects/pack");
3761 fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3762 fs::write(pack_dir.join("tmp_pack_fresh"), b"new").unwrap();
3763 fs::create_dir(pack_dir.join(".pack-deadbeef-install-9_0")).unwrap();
3764 fs::write(pack_dir.join(".notes-install-backup"), b"notes").unwrap();
3765 fs::create_dir(pack_dir.join(".cache-install-state")).unwrap();
3766 fs::write(
3767 pack_dir.join(".pack-deadbeef-install-stale-file"),
3768 b"not a stage dir",
3769 )
3770 .unwrap();
3771 let cutoff = SystemTime::now() - std::time::Duration::from_secs(21 * 86400);
3772 filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3773 filetime::set_file_mtime(
3774 pack_dir.join(".pack-deadbeef-install-9_0"),
3775 FileTime::from(cutoff),
3776 )
3777 .unwrap();
3778 for path in [
3779 pack_dir.join(".notes-install-backup"),
3780 pack_dir.join(".cache-install-state"),
3781 pack_dir.join(".pack-deadbeef-install-stale-file"),
3782 ] {
3783 filetime::set_file_mtime(&path, FileTime::from(cutoff)).unwrap();
3784 }
3785 gc(&repo, &GcOptions::default()).expect("gc");
3786 assert!(!pack_dir.join("tmp_pack_old").exists());
3787 assert!(!pack_dir.join(".pack-deadbeef-install-9_0").exists());
3788 assert!(pack_dir.join("tmp_pack_fresh").exists());
3789 assert!(pack_dir.join(".notes-install-backup").exists());
3790 assert!(pack_dir.join(".cache-install-state").is_dir());
3791 assert!(pack_dir.join(".pack-deadbeef-install-stale-file").is_file());
3792
3793 fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3794 filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3795 let stats = gc(
3796 &repo,
3797 &GcOptions {
3798 repack: RepackOptions {
3799 dry_run: true,
3800 ..RepackOptions::default()
3801 },
3802 ..GcOptions::default()
3803 },
3804 )
3805 .expect("dry-run gc");
3806 assert!(stats.prune.pruned >= 1);
3807 assert!(pack_dir.join("tmp_pack_old").exists());
3808 }
3809
3810 #[test]
3811 fn gc_prunes_stale_tmp_objdir_incoming_quarantine() {
3812 let (_dir, repo) = repo_with_config("");
3813 let objects = repo.git_dir().join("objects");
3814 let stale = objects.join("tmp_objdir-incoming-99999-0");
3815 fs::create_dir_all(stale.join("pack")).unwrap();
3816 fs::write(stale.join("pack/tmp_pack_stale"), b"partial pack").unwrap();
3817 let fresh = objects.join("tmp_objdir-incoming-1-0");
3818 fs::create_dir_all(fresh.join("pack")).unwrap();
3819 fs::write(fresh.join("pack/tmp_pack_fresh"), b"new").unwrap();
3820 let cutoff = SystemTime::now() - std::time::Duration::from_secs(21 * 86400);
3821 filetime::set_file_mtime(&stale, FileTime::from(cutoff)).unwrap();
3822 filetime::set_file_mtime(stale.join("pack/tmp_pack_stale"), FileTime::from(cutoff))
3823 .unwrap();
3824 gc(&repo, &GcOptions::default()).expect("gc");
3825 assert!(!stale.exists(), "stale receive quarantine should be pruned");
3826 assert!(fresh.exists(), "fresh receive quarantine should be kept");
3827 }
3828
3829 #[test]
3830 fn gc_reaps_stale_next_index_files_in_git_and_linked_worktree() {
3831 use crate::worktree::{add_linked_worktree, AddLinkedWorktreeOptions, WorktreeBranch};
3832
3833 let (dir, repo) = repo_with_config("");
3834 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3835 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3836 let ident = "Test <t@example.com> 1 +0000".to_string();
3837 create_commit(
3838 &repo,
3839 &CommitRequest {
3840 message: "init".into(),
3841 author: Some(ident.parse().expect("signature")),
3842 committer: Some(ident.parse().expect("signature")),
3843 allow_empty: false,
3844 sign_override: None,
3845 amend: false,
3846 assume_worktree_synced: false,
3847 skip_pre_commit_tree_match: false,
3848 staged_change_count: None,
3849 ..Default::default()
3850 },
3851 &mut NullProgress,
3852 &NeverCancel,
3853 )
3854 .expect("commit");
3855 let main_git = repo.git_dir().to_path_buf();
3856 let dead_pid = 99_999_997_u64;
3857 let live_pid = u64::from(std::process::id());
3858 fs::write(
3859 main_git.join(format!("next-index-{dead_pid}")),
3860 b"main orphan",
3861 )
3862 .unwrap();
3863 fs::write(
3864 main_git.join(format!("next-index-{live_pid}")),
3865 b"main live",
3866 )
3867 .unwrap();
3868
3869 let linked_path = dir.path().join("linked-wt");
3870 add_linked_worktree(
3871 &repo,
3872 &AddLinkedWorktreeOptions {
3873 path: linked_path.clone(),
3874 branch: Some(WorktreeBranch::Create("linked".into())),
3875 start_point: Some("HEAD".into()),
3876 force: false,
3877 lock: None,
3878 no_checkout: true,
3879 track: false,
3880 },
3881 )
3882 .expect("linked worktree");
3883 let linked_git = crate::worktree::list_worktrees(&repo)
3884 .unwrap()
3885 .into_iter()
3886 .find(|e| e.path == linked_path)
3887 .expect("linked entry")
3888 .admin_dir;
3889 fs::write(
3890 linked_git.join(format!("next-index-{dead_pid}")),
3891 b"linked orphan",
3892 )
3893 .unwrap();
3894
3895 gc(&repo, &GcOptions::default()).expect("gc");
3896 assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3897 assert!(main_git.join(format!("next-index-{live_pid}")).exists());
3898 assert!(!linked_git.join(format!("next-index-{dead_pid}")).exists());
3899
3900 fs::write(main_git.join(format!("next-index-{dead_pid}")), b"again").unwrap();
3901 gc(
3902 &repo,
3903 &GcOptions {
3904 repack: RepackOptions {
3905 dry_run: true,
3906 ..RepackOptions::default()
3907 },
3908 ..GcOptions::default()
3909 },
3910 )
3911 .expect("dry-run gc");
3912 assert!(main_git.join(format!("next-index-{dead_pid}")).exists());
3913 gc(&repo, &GcOptions::default()).expect("gc again");
3914 assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3915 }
3916}