1use std::collections::{HashMap, HashSet};
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use std::time::{Instant, SystemTime};
22
23use crate::commit_graph_file::CommitGraphChain;
24use crate::commit_graph_write::{
25 atomic_replace_commit_graph, build_commit_graph_bytes_from_oid_maps,
26 collect_reachable_commit_oids_for_repository, load_commit_graph_commit_info,
27 remove_commit_graph_chain_dir, remove_commit_graph_files, BloomWriteStats,
28};
29use crate::config::ConfigSet;
30use crate::diff::bloom::BloomFilterSettings;
31use crate::environment::Environment;
32use crate::error::{ConfigError, Error, Result};
33use crate::fsck as repo_fsck;
34use crate::gc::{prune_loose_unreachable_with, LazyReachable, PruneStats, S_IFDIR, S_IFMT};
35use crate::git_date::approx::approxidate_careful_at;
36use crate::index_pack::{install_pack_bytes, install_pack_path, IngestPackOptions};
37use crate::midx::{
38 clear_pack_midx_state, midx_version_from_config, midx_write_version,
39 write_multi_pack_index_with_options, MidxBitmapWriteOutcome, WriteMultiPackIndexOptions,
40 WriteMultiPackIndexResult,
41};
42use crate::objects::{
43 oid_hash_map, tag_object_line_oid, CommitRef, FileMode, ObjectId, ObjectKind, OidHashSet,
44 TreeRefIter,
45};
46use crate::odb::Odb;
47use crate::odb::UnreachablePackRetention;
48use crate::pack_bitmap::{PackBitmapWriteOptions, PackBitmapWriter};
49use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
50use crate::pack_name_hash::pack_name_hash_child;
51use crate::pack_object_select::{
52 enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
53};
54use crate::pack_objects::{PackBuildOptions, SerializedDeltaPack};
55use crate::pack_rev::write_pack_rev_sidecar_for_index_path;
56use crate::prune_packed::{prune_packed_objects, PrunePackedOptions};
57use crate::reflog::{
58 expire_reflog_git_with_promises, load_gc_reflog_expire_config, reflog_refs_on_store,
59 ReflogExpireParams,
60};
61use crate::repack_pack::{write_repack_pack, RepackPackSettings};
62use crate::repo::Repository;
63use crate::shallow::load_shallow_boundaries;
64use time::OffsetDateTime;
65
66#[derive(Debug, Clone)]
68#[non_exhaustive]
69pub struct RepackOptions {
70 pub dry_run: bool,
72 pub write_commit_graph: bool,
74 pub write_bitmap: bool,
76 pub prune_older_than: Option<OffsetDateTime>,
78 pub now: Option<OffsetDateTime>,
80 pub keep_unreachable: bool,
82 pub threads: Option<usize>,
84 pub pack_build: Option<PackBuildOptions>,
91 pub merged_config: Option<ConfigSet>,
93 pub environment: Environment,
95 pub incremental: bool,
104 pub auto_skip: bool,
108 pub force_recompress: bool,
112}
113
114impl Default for RepackOptions {
115 fn default() -> Self {
116 Self {
117 dry_run: false,
118 write_commit_graph: false,
119 write_bitmap: false,
120 prune_older_than: None,
121 now: None,
122 keep_unreachable: true,
123 threads: None,
124 pack_build: None,
125 merged_config: None,
126 environment: Environment::empty(),
127 incremental: false,
128 auto_skip: false,
129 force_recompress: false,
130 }
131 }
132}
133
134impl RepackOptions {
135 #[must_use]
137 pub fn with_dry_run(dry_run: bool) -> Self {
138 Self {
139 dry_run,
140 ..Self::default()
141 }
142 }
143}
144
145struct RepackDetail {
146 report: RepackReport,
147 pack_path: Option<PathBuf>,
148 prune_packed_loose: usize,
149 unreachable_loose: PruneStats,
150 unreachable_prune_ms: u64,
151 unreachable_prune_ran: bool,
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
156#[non_exhaustive]
157pub struct RepackReport {
158 pub packs_written: usize,
160 pub packs_removed: usize,
162 pub loose_removed: usize,
164 pub objects: usize,
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq)]
170#[non_exhaustive]
171pub struct RepackStats {
172 pub loose_before: usize,
174 pub packs_before: usize,
176 pub objects_packed: Option<usize>,
179 pub pack_bytes: Option<u64>,
181 pub pack_path: Option<PathBuf>,
184 pub removed_pack_files: usize,
186 pub pruned_loose: usize,
188 pub unreachable_loose_pruned: usize,
190 pub unreachable_loose_kept: usize,
192 pub unreachable_prune_ms: u64,
194 pub unreachable_prune_ran: bool,
196}
197
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct CommitGraphOptions {
202 pub changed_paths: bool,
204 pub dry_run: bool,
206}
207
208impl CommitGraphOptions {
209 #[must_use]
211 pub fn with_changed_paths(changed_paths: bool) -> Self {
212 Self {
213 changed_paths,
214 ..Self::default()
215 }
216 }
217
218 #[must_use]
220 pub fn with_dry_run(dry_run: bool) -> Self {
221 Self {
222 dry_run,
223 ..Self::default()
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub struct CommitGraphWriteReport {
232 pub path: PathBuf,
234 pub commit_count: usize,
236 pub removed_chain: bool,
238 pub bloom_stats: Option<BloomWriteStats>,
241 pub skipped: Option<CommitGraphSkipReason>,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251#[non_exhaustive]
252pub enum CommitGraphSkipReason {
253 Shallow,
255 Grafts,
257 ReplaceRefs,
259}
260
261impl CommitGraphSkipReason {
262 #[must_use]
264 pub fn as_str(self) -> &'static str {
265 match self {
266 Self::Shallow => "shallow",
267 Self::Grafts => "grafts",
268 Self::ReplaceRefs => "replace-refs",
269 }
270 }
271
272 pub(crate) fn detect(
278 git_dir: &Path,
279 has_replace_refs: impl FnOnce() -> Result<bool>,
280 ) -> Result<Option<Self>> {
281 let common = crate::repo::common_git_dir_for_config(git_dir);
282 if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
283 return Ok(Some(Self::Shallow));
284 }
285 if !crate::rev_parse::load_graft_parents(&common).is_empty() {
286 return Ok(Some(Self::Grafts));
287 }
288 Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
289 }
290
291 pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
294 Self::detect(repo.git_dir(), || {
295 let Some(base) = repo.replace_ref_base_if_enabled() else {
296 return Ok(false);
297 };
298 Ok(!repo.references().list(&base)?.is_empty())
299 })
300 }
301}
302
303#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
305#[non_exhaustive]
306pub struct MidxOptions {
307 pub bitmap: bool,
309 pub dry_run: bool,
311 pub version: Option<u8>,
315}
316
317impl MidxOptions {
318 #[must_use]
320 pub fn with_bitmap(bitmap: bool) -> Self {
321 Self {
322 bitmap,
323 ..Self::default()
324 }
325 }
326
327 #[must_use]
329 pub fn with_dry_run(dry_run: bool) -> Self {
330 Self {
331 dry_run,
332 ..Self::default()
333 }
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
339#[non_exhaustive]
340pub struct MidxWriteReport {
341 pub path: PathBuf,
343 pub pack_count: usize,
345 pub bitmap: MidxBitmapWriteOutcome,
347}
348
349#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
351#[non_exhaustive]
352pub struct PackRefsOptions {
353 pub all: bool,
355 pub dry_run: bool,
357}
358
359impl PackRefsOptions {
360 #[must_use]
362 pub fn with_all(all: bool) -> Self {
363 Self {
364 all,
365 ..Self::default()
366 }
367 }
368
369 #[must_use]
371 pub fn with_dry_run(dry_run: bool) -> Self {
372 Self {
373 dry_run,
374 ..Self::default()
375 }
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381#[non_exhaustive]
382pub struct PackRefsReport {
383 pub path: PathBuf,
385 pub refs_packed: usize,
387 pub refs_pruned: usize,
389}
390
391#[derive(Debug, Clone)]
393#[non_exhaustive]
394pub struct GcOptions {
395 pub repack: RepackOptions,
397 pub expire_reflog: bool,
399 pub now_unix: i64,
401 pub pack_refs: bool,
403 pub write_commit_graph: bool,
408}
409
410impl Default for GcOptions {
411 fn default() -> Self {
412 Self {
413 repack: RepackOptions {
414 auto_skip: true,
415 ..RepackOptions::default()
416 },
417 expire_reflog: true,
418 now_unix: 0,
419 pack_refs: true,
420 write_commit_graph: true,
421 }
422 }
423}
424
425impl GcOptions {
426 #[must_use]
428 pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
429 Self {
430 repack: RepackOptions {
431 dry_run,
432 auto_skip: true,
433 ..RepackOptions::default()
434 },
435 expire_reflog: true,
436 now_unix,
437 ..Self::default()
438 }
439 }
440
441 #[must_use]
443 pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
444 Self {
445 repack: RepackOptions {
446 auto_skip: true,
447 ..RepackOptions::default()
448 },
449 expire_reflog,
450 now_unix,
451 ..Self::default()
452 }
453 }
454
455 #[must_use]
457 pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
458 Self {
459 repack: RepackOptions {
460 dry_run,
461 auto_skip: true,
462 ..RepackOptions::default()
463 },
464 expire_reflog: true,
465 now_unix,
466 pack_refs: true,
467 write_commit_graph: true,
468 ..Self::default()
469 }
470 }
471}
472
473impl PruneOptions {
474 #[must_use]
476 pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
477 Self {
478 older_than,
479 ..Self::default()
480 }
481 }
482}
483
484pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
493 repo_fsck::reachability_roots(repo)
494}
495
496pub(crate) fn bloom_settings_for_commit_graph_write(
497 config: &ConfigSet,
498 changed_paths: bool,
499) -> BloomFilterSettings {
500 let mut bloom = BloomFilterSettings::default();
501 if !changed_paths {
502 return bloom;
503 }
504 if let Some(v) = config
505 .get("commitgraph.changedpathsversion")
506 .and_then(|s| s.trim().parse::<i32>().ok())
507 {
508 if v == 2 {
509 bloom.hash_version = 2;
510 } else if v == 1 {
511 bloom.hash_version = 1;
512 }
513 }
514 bloom
515}
516
517fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
523 repo.odb()
524 .files_objects_dir()
525 .and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
526 .is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
527}
528
529pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
530 config
531 .get("commitgraph.generationversion")
532 .and_then(|s| s.trim().parse::<i32>().ok())
533 .map(|v| v >= 2)
534 .unwrap_or(true)
535}
536
537pub fn write_commit_graph(
553 repo: &Repository,
554 opts: &CommitGraphOptions,
555) -> Result<CommitGraphWriteReport> {
556 let odb = repo.odb();
557 odb.require_files_primary("commit-graph write")?;
558 let objects_dir = odb
559 .files_objects_dir()
560 .ok_or(Error::UnsupportedObjectStore {
561 operation: "commit-graph write",
562 })?;
563 let path = objects_dir.join("info").join("commit-graph");
564
565 if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
566 let removed_chain = if opts.dry_run {
567 false
568 } else {
569 let removed = remove_commit_graph_files(objects_dir)?;
570 repo.caches().invalidate_commit_graph_cache();
571 removed
572 };
573 return Ok(CommitGraphWriteReport {
574 path,
575 commit_count: 0,
576 removed_chain,
577 bloom_stats: None,
578 skipped: Some(reason),
579 });
580 }
581
582 let config = repo.config()?;
583 let bloom = bloom_settings_for_commit_graph_write(&config, opts.changed_paths);
584 let write_generation = commit_graph_writes_generation_data(&config);
585
586 let chain = CommitGraphChain::try_load(objects_dir).ok().flatten();
587
588 let commits = collect_reachable_commit_oids_for_repository(repo)?;
589 let mut sorted: Vec<ObjectId> = commits.into_iter().collect();
590 sorted.sort();
591 let commit_count = sorted.len();
592
593 if opts.dry_run {
594 return Ok(CommitGraphWriteReport {
595 path,
596 commit_count,
597 removed_chain: false,
598 bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
599 skipped: None,
600 });
601 }
602
603 let mut infos = oid_hash_map();
604 for oid in &sorted {
605 infos.insert(*oid, load_commit_graph_commit_info(odb, *oid)?);
606 }
607
608 let mut existing_filters = oid_hash_map();
609 let mut upgraded_filters = oid_hash_map();
610 if opts.changed_paths {
611 if let Some(ref chain) = chain {
612 for oid in &sorted {
613 if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
614 existing_filters.insert(*oid, bytes);
615 } else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
616 upgraded_filters.insert(*oid, bytes);
617 }
618 }
619 }
620 }
621
622 let (bytes, bloom_stats) = build_commit_graph_bytes_from_oid_maps(
623 &sorted,
624 &infos,
625 odb,
626 opts.changed_paths,
627 &bloom,
628 None,
629 &[],
630 None,
631 &existing_filters,
632 &upgraded_filters,
633 write_generation,
634 )?;
635
636 let written_path = atomic_replace_commit_graph(objects_dir, &bytes)?;
637 let removed_chain = remove_commit_graph_chain_dir(objects_dir);
638 repo.caches().invalidate_commit_graph_cache();
639
640 Ok(CommitGraphWriteReport {
641 path: written_path,
642 commit_count,
643 removed_chain,
644 bloom_stats: opts.changed_paths.then_some(bloom_stats),
645 skipped: None,
646 })
647}
648
649pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
659 let odb = repo.odb();
660 odb.require_files_primary("midx write")?;
661 let requested = match opts.version {
662 Some(v) => Some(v),
663 None => midx_version_from_config(&*repo.config()?)?,
664 };
665 let version = Some(midx_write_version(requested)?);
666 let objects_dir = odb
667 .files_objects_dir()
668 .ok_or(Error::UnsupportedObjectStore {
669 operation: "midx write",
670 })?;
671 let pack_dir = objects_dir.join("pack");
672 let pack_count = fs::read_dir(&pack_dir)
673 .map(|rd| {
674 rd.filter_map(|e| e.ok())
675 .filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
676 .filter(|e| e.path().with_extension("pack").is_file())
677 .count()
678 })
679 .unwrap_or(0);
680
681 let path = pack_dir.join("multi-pack-index");
682 if opts.dry_run {
683 return Ok(MidxWriteReport {
684 path,
685 pack_count,
686 bitmap: MidxBitmapWriteOutcome::NotRequested,
687 });
688 }
689 let write_opts = WriteMultiPackIndexOptions {
690 write_bitmap: opts.bitmap,
691 version,
692 ..WriteMultiPackIndexOptions::default()
693 };
694 let WriteMultiPackIndexResult { bitmap } =
695 write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
696 Ok(MidxWriteReport {
697 path,
698 pack_count,
699 bitmap,
700 })
701}
702
703#[derive(Debug, Clone, Default)]
705#[non_exhaustive]
706pub struct PruneOptions {
707 pub older_than: Option<SystemTime>,
711 pub dry_run: bool,
713}
714
715#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
717#[non_exhaustive]
718pub struct GcPhaseTimings {
719 pub pack_refs_ms: u64,
721 pub reflog_ms: u64,
723 pub repack_ms: u64,
725 pub prune_ms: u64,
727 pub commit_graph_ms: u64,
729}
730
731#[derive(Debug, Clone, Default, PartialEq, Eq)]
733pub struct GcStats {
734 pub pack_refs: Option<PackRefsReport>,
736 pub repack: RepackStats,
738 pub prune: PruneStats,
740 pub prune_ran: bool,
742 pub commit_graph: Option<CommitGraphWriteReport>,
744 pub reflog_entries_pruned: usize,
746 pub phase_timings: GcPhaseTimings,
748}
749
750pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
764 Ok(repack_detail(repo, opts)?.report)
765}
766
767fn repack_detail(repo: &Repository, opts: &RepackOptions) -> Result<RepackDetail> {
768 let odb = repo.odb();
769 odb.require_files_primary("repack")?;
770 let objects_dir = odb
771 .files_objects_dir()
772 .ok_or(Error::UnsupportedObjectStore {
773 operation: "repack",
774 })?;
775 let pack_dir = objects_dir.join("pack");
776
777 let mut report = RepackReport::default();
778 let loose_before = count_loose_objects(odb)?;
779 let packs_before = count_pack_files(&pack_dir);
780
781 let roots = collect_maintenance_object_roots(repo)?;
782 if roots.is_empty() && loose_before == 0 && packs_before == 0 {
783 return Ok(RepackDetail {
784 report,
785 pack_path: None,
786 prune_packed_loose: 0,
787 unreachable_loose: PruneStats::default(),
788 unreachable_prune_ms: 0,
789 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
790 });
791 }
792
793 let cfg = opts
794 .merged_config
795 .clone()
796 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
797 let mut pack_opts = opts
798 .pack_build
799 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
800 if opts.force_recompress {
801 pack_opts.reuse_deltas = false;
802 }
803
804 let run_incremental = opts.incremental;
805 let shallow = load_shallow_boundaries(repo.git_dir());
806 let reachable = LazyReachable::new(odb, &roots, &shallow);
807
808 let skip_repack = !opts.force_recompress
809 && !opts.write_bitmap
810 && !opts.write_commit_graph
811 && should_auto_skip_repack(
812 loose_before,
813 packs_before,
814 run_incremental,
815 opts.auto_skip,
816 cfg.as_ref(),
817 )
818 && !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
819
820 if skip_repack {
821 reachable.require_roots_exist()?;
824 return Ok(RepackDetail {
825 report,
826 pack_path: None,
827 prune_packed_loose: 0,
828 unreachable_loose: PruneStats::default(),
829 unreachable_prune_ms: 0,
830 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
831 });
832 }
833 let promised = reachable.promised()?;
834 let partial_clone = !promised.is_empty()
835 || cfg.as_ref().is_some_and(|config| {
836 crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config)
837 });
838 if partial_clone {
839 reachable.get()?;
842 }
843 if opts.dry_run {
844 report.objects = if run_incremental {
845 reachable_loose_objects(odb, &reachable)?.len()
846 } else {
847 if !opts.keep_unreachable {
848 reachable
849 .get()?
850 .iter()
851 .filter(|oid| !promised.contains(oid))
852 .count()
853 } else {
854 repack_object_list(odb, &roots, &shallow, true, pack_opts.use_bitmaps, promised)?
855 .send
856 .len()
857 }
858 };
859 return Ok(RepackDetail {
860 report,
861 pack_path: None,
862 prune_packed_loose: 0,
863 unreachable_loose: PruneStats::default(),
864 unreachable_prune_ms: 0,
865 unreachable_prune_ran: false,
866 });
867 }
868
869 let retained_packs = if run_incremental {
870 HashSet::new()
871 } else {
872 packs_holding_available_promises(odb, promised)?
873 };
874 let built_pack = if run_incremental {
875 let (loose, name_hashes) = named_reachable_loose_objects(odb, &reachable, &roots)?;
876 write_staged_repack_pack(
877 odb,
878 &pack_dir,
879 &loose,
880 &name_hashes,
881 &pack_opts,
882 opts.threads,
883 cfg.as_ref(),
884 )?
885 } else {
886 build_repack_pack(
887 odb,
888 &pack_dir,
889 &roots,
890 &shallow,
891 opts.keep_unreachable,
892 &pack_opts,
893 opts.threads,
894 cfg.as_ref(),
895 promised,
896 )?
897 };
898 let pack_bytes_len = built_pack
899 .staging_path
900 .as_ref()
901 .and_then(|p| std::fs::metadata(p).ok())
902 .map(|m| m.len() as usize)
903 .unwrap_or_else(|| built_pack.bytes.len());
904 let mut prune_packed_loose = 0usize;
905 let mut unreachable_loose = PruneStats::default();
906 let mut installed_pack = None;
907 let pack_trailer_len = 12 + odb.hash_algo().len();
908 if pack_bytes_len > pack_trailer_len {
909 clear_pack_midx_state(&pack_dir)?;
910
911 let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
912 let ingest_opts = IngestPackOptions {
913 fix_thin: false,
914 threads: opts.threads,
915 merged_config: cfg.clone(),
916 environment: opts.environment.clone(),
917 skip_post_index_verify: trusted_rows.is_some(),
919 trusted_index_rows: trusted_rows,
920 ..IngestPackOptions::default()
921 };
922 let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
923 install_pack_path(staging, odb, &ingest_opts)?
924 } else {
925 install_pack_bytes(built_pack.bytes, odb, &ingest_opts)?
926 };
927 report.packs_written = 1;
928 report.objects = ingested.object_ids.len();
929 let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
930 operation: "repack",
931 })?;
932
933 let idx_path = pack_path.with_extension("idx");
934 if cfg
935 .as_ref()
936 .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
937 {
938 write_pack_rev_sidecar_for_index_path(&idx_path)?;
939 }
940
941 if opts.write_bitmap && promised.is_empty() {
942 if opts.incremental && packs_before > 0 {
943 let version = match cfg.as_ref() {
947 Some(c) => midx_version_from_config(c)?,
948 None => None,
949 };
950 let midx_opts = WriteMultiPackIndexOptions {
951 write_bitmap: true,
952 version,
953 ..WriteMultiPackIndexOptions::default()
954 };
955 let WriteMultiPackIndexResult { bitmap: _ } =
956 write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
957 } else {
958 let bitmap_opts = PackBitmapWriteOptions::default();
959 let now = opts
960 .now
961 .map(offset_to_system_time)
962 .unwrap_or(SystemTime::UNIX_EPOCH);
963 PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now)
964 .map_err(|e| Error::CorruptObject(e.to_string()))?;
965 }
966 }
967
968 let retention = if opts.keep_unreachable {
969 UnreachablePackRetention::KeepAll
970 } else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
971 UnreachablePackRetention::KeepGraceProtected(cutoff)
972 } else {
973 UnreachablePackRetention::DropAll
974 };
975 if !opts.incremental {
977 odb.loosen_unreachable_from_superseded_packs(
978 &pack_dir,
979 &pack_path,
980 &reachable,
981 retention,
982 &retained_packs,
983 )?;
984 }
985
986 odb.invalidate_packs();
988
989 report.packs_removed = if opts.incremental {
990 0
991 } else {
992 remove_superseded_pack_files_retaining(&pack_dir, &pack_path, &retained_packs)?
993 };
994
995 prune_packed_loose =
996 prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
997
998 installed_pack = Some(pack_path);
999
1000 if opts.write_commit_graph {
1001 write_commit_graph(
1002 repo,
1003 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
1004 repo,
1005 )),
1006 )?;
1007 }
1008 }
1009
1010 let mut unreachable_prune_ms = 0u64;
1011 let mut unreachable_prune_ran = false;
1012 if !opts.keep_unreachable {
1013 let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
1014 let prune_start = Instant::now();
1015 unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
1016 unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
1017 unreachable_prune_ran = true;
1018 }
1019
1020 report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
1021 Ok(RepackDetail {
1022 report,
1023 pack_path: installed_pack,
1024 prune_packed_loose,
1025 unreachable_loose,
1026 unreachable_prune_ms,
1027 unreachable_prune_ran,
1028 })
1029}
1030
1031pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
1033 let odb = repo.odb();
1034 let objects_dir = odb
1035 .files_objects_dir()
1036 .ok_or(Error::UnsupportedObjectStore {
1037 operation: "repack",
1038 })?;
1039 let pack_dir = objects_dir.join("pack");
1040 let mut stats = RepackStats {
1041 loose_before: count_loose_objects(odb)?,
1042 packs_before: count_pack_files(&pack_dir),
1043 ..RepackStats::default()
1044 };
1045 if opts.dry_run {
1046 let count = repack_detail(repo, opts)?.report.objects;
1047 stats.objects_packed = (count > 0).then_some(count);
1048 return Ok(stats);
1049 }
1050 let detail = repack_detail(repo, opts)?;
1051 let report = detail.report;
1052 stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
1053 stats.removed_pack_files = report.packs_removed;
1054 stats.pruned_loose = detail.prune_packed_loose;
1055 stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
1056 stats.unreachable_loose_kept = detail.unreachable_loose.kept;
1057 stats.unreachable_prune_ms = detail.unreachable_prune_ms;
1058 stats.unreachable_prune_ran = detail.unreachable_prune_ran;
1059 stats.pack_path = detail.pack_path;
1060 stats.pack_bytes = stats
1061 .pack_path
1062 .as_ref()
1063 .and_then(|p| p.metadata().ok().map(|m| m.len()));
1064 Ok(stats)
1065}
1066
1067fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
1068 SystemTime::UNIX_EPOCH
1069 + std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
1070}
1071
1072fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
1073 match policy {
1074 GcLoosePrunePolicy::Disabled => {
1075 repack.keep_unreachable = true;
1076 }
1077 GcLoosePrunePolicy::Enabled { keep_newer_than } => {
1078 repack.keep_unreachable = false;
1079 repack.prune_older_than = keep_newer_than.map(|t| {
1080 let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
1081 OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
1082 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
1083 });
1084 }
1085 }
1086}
1087
1088fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> bool {
1089 if let Some(Ok(v)) = config.get_bool("repack.writeBitmaps") {
1090 return v;
1091 }
1092 repo.work_tree().is_none()
1093}
1094
1095fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1098 let loose = odb.enumerate_local_loose_objects()?;
1099 if loose.is_empty() {
1100 return Ok(Vec::new());
1101 }
1102 let promised = reachable.promised()?;
1103 let reachable = reachable.get()?;
1104 Ok(loose
1105 .into_iter()
1106 .map(|(oid, _)| oid)
1107 .filter(|oid| reachable.contains(oid) && !promised.contains(oid))
1108 .collect())
1109}
1110
1111fn named_reachable_loose_objects(
1119 odb: &Odb,
1120 reachable: &LazyReachable<'_>,
1121 roots: &[ObjectId],
1122) -> Result<(Vec<ObjectId>, HashMap<ObjectId, u32>)> {
1123 let loose = reachable_loose_objects(odb, reachable)?;
1124 let mut pending: OidHashSet = loose.iter().copied().collect();
1125 let mut order = Vec::with_capacity(loose.len());
1126 let mut names = HashMap::new();
1127 let mut commits = Vec::new();
1128 let mut root_trees = Vec::new();
1129 let mut stack: Vec<ObjectId> = roots
1130 .iter()
1131 .filter(|oid| pending.contains(*oid))
1132 .copied()
1133 .collect();
1134 let mut seen = OidHashSet::default();
1135 while let Some(oid) = stack.pop() {
1136 if !seen.insert(oid) {
1137 continue;
1138 }
1139 match odb.read_info(&oid)?.kind {
1140 ObjectKind::Commit => {
1141 let obj = odb.read(&oid)?;
1142 let commit = CommitRef::parse(&obj.data)?;
1143 commits.push((
1144 commit.committer_unix_seconds_for_ordering(),
1145 oid,
1146 commit.tree(),
1147 ));
1148 stack.extend(commit.parents().iter().filter(|p| pending.contains(*p)));
1149 }
1150 ObjectKind::Tag => {
1151 let obj = odb.read(&oid)?;
1152 pending.remove(&oid);
1153 order.push(oid);
1154 stack.extend(tag_object_line_oid(&obj.data).filter(|t| pending.contains(t)));
1155 }
1156 ObjectKind::Tree => root_trees.push(oid),
1157 ObjectKind::Blob => {}
1158 }
1159 }
1160 commits.sort_by_key(|&(time, _, _)| std::cmp::Reverse(time));
1161 for &(_, oid, _) in &commits {
1162 pending.remove(&oid);
1163 order.push(oid);
1164 }
1165 let hash_len = odb.hash_algo().len();
1166 let mut trees: Vec<(ObjectId, Option<u32>)> = Vec::new();
1167 for tree in commits.iter().map(|&(_, _, tree)| tree).chain(root_trees) {
1168 trees.push((tree, None));
1169 while let Some((tree, name_hash)) = trees.pop() {
1170 if !pending.remove(&tree) {
1171 continue;
1172 }
1173 order.push(tree);
1174 if let Some(hash) = name_hash {
1175 names.insert(tree, hash);
1176 }
1177 let obj = odb.read(&tree)?;
1178 for entry in TreeRefIter::with_oid_len(&obj.data, hash_len) {
1179 let entry = entry?;
1180 if FileMode::new(entry.mode).is_gitlink() || !pending.contains(&entry.oid) {
1181 continue;
1182 }
1183 let hash = pack_name_hash_child(name_hash, entry.name);
1184 if entry.mode & S_IFMT == S_IFDIR {
1185 trees.push((entry.oid, Some(hash)));
1186 } else {
1187 pending.remove(&entry.oid);
1188 order.push(entry.oid);
1189 names.insert(entry.oid, hash);
1190 }
1191 }
1192 }
1193 }
1194 order.extend(loose.into_iter().filter(|oid| pending.contains(oid)));
1195 Ok((order, names))
1196}
1197
1198fn should_auto_skip_repack(
1203 loose_objects: usize,
1204 pack_count: usize,
1205 incremental: bool,
1206 auto_skip: bool,
1207 _config: Option<&ConfigSet>,
1208) -> bool {
1209 if !auto_skip || incremental {
1210 return false;
1211 }
1212 if pack_count > 1 {
1213 return false;
1214 }
1215 if pack_count == 1 {
1216 return loose_objects == 0;
1217 }
1218 loose_objects == 0
1219}
1220
1221fn skipping_repack_would_retain_prunable_packed_objects(
1226 odb: &Odb,
1227 pack_dir: &Path,
1228 reachable: &LazyReachable<'_>,
1229 opts: &RepackOptions,
1230) -> Result<bool> {
1231 if opts.keep_unreachable {
1232 return Ok(false);
1233 }
1234 let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1235 UnreachablePackRetention::KeepGraceProtected(cutoff)
1236 } else {
1237 UnreachablePackRetention::DropAll
1238 };
1239 let indexes = odb.local_pack_indexes()?;
1240 for idx in indexes {
1241 if idx.is_promisor || pack_has_keep_marker(pack_dir, &idx.pack_path) {
1242 continue;
1243 }
1244 let pack_mtime = fs::metadata(&idx.pack_path)
1245 .and_then(|m| m.modified())
1246 .unwrap_or(SystemTime::UNIX_EPOCH);
1247 let drop_unreachable_from_pack = match retention {
1248 UnreachablePackRetention::KeepAll => false,
1249 UnreachablePackRetention::DropAll => true,
1250 UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1251 };
1252 if !drop_unreachable_from_pack {
1253 continue;
1254 }
1255 let reachable = reachable.get()?;
1256 for entry in idx.iter() {
1257 let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1258 continue;
1259 };
1260 if !reachable.contains(&oid) {
1261 return Ok(true);
1262 }
1263 }
1264 }
1265 Ok(false)
1266}
1267
1268pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1274 let odb = repo.odb();
1275 odb.require_files_primary("prune")?;
1276 let cfg = repo.config()?;
1277 let now = gc_now_unix(0);
1278 let keep_newer_than = match opts.older_than {
1279 Some(t) => Some(t),
1280 None => match gc_loose_prune_policy(&cfg, now)? {
1281 GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1282 GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1283 },
1284 };
1285 let roots = collect_maintenance_object_roots(repo)?;
1286 let shallow = load_shallow_boundaries(repo.git_dir());
1287 prune_loose_unreachable_with(
1288 odb,
1289 &LazyReachable::new(odb, &roots, &shallow),
1290 keep_newer_than,
1291 opts.dry_run,
1292 )
1293}
1294
1295fn duration_ms(d: std::time::Duration) -> u64 {
1296 d.as_millis() as u64
1297}
1298
1299pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1309 repo.odb().require_files_primary("gc")?;
1310 let now = gc_now_unix(opts.now_unix);
1311 let _lock = if opts.repack.dry_run {
1312 None
1313 } else {
1314 match GcLock::try_acquire(repo, now)? {
1315 GcLockAttempt::Acquired(lock) => Some(lock),
1316 GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1317 }
1318 };
1319 gc_locked(repo, opts, now)
1320}
1321
1322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1324pub enum AutoGcReason {
1325 TooManyLoose {
1328 estimate: usize,
1330 limit: usize,
1332 },
1333 TooManyPacks {
1336 count: usize,
1338 limit: usize,
1340 },
1341}
1342
1343const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1345
1346pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1361 let cfg = repo.config()?;
1362 let Some(threshold) = cfg.gc_auto_threshold() else {
1363 return Ok(None);
1364 };
1365 let Some(objects_dir) = repo.odb().files_objects_dir() else {
1366 return Ok(None);
1367 };
1368 if let Some(limit) = cfg.gc_auto_pack_limit() {
1369 let count = count_pack_files(&objects_dir.join("pack"));
1370 if count > limit {
1371 return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1372 }
1373 }
1374 let name_len = repo.odb().hash_algo().hex_len() - 2;
1375 let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1376 if sampled > threshold.div_ceil(256) {
1377 return Ok(Some(AutoGcReason::TooManyLoose {
1378 estimate: sampled.saturating_mul(256),
1379 limit: threshold,
1380 }));
1381 }
1382 Ok(None)
1383}
1384
1385fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1387 let entries = match fs::read_dir(dir) {
1388 Ok(entries) => entries,
1389 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1390 Err(e) => return Err(Error::Io(e)),
1391 };
1392 Ok(entries
1393 .filter_map(|e| e.ok())
1394 .filter(|e| {
1395 let name = e.file_name();
1396 let name = name.as_encoded_bytes();
1397 name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1398 })
1399 .count())
1400}
1401
1402#[derive(Debug, Clone, PartialEq, Eq)]
1404#[non_exhaustive]
1405pub enum AutoGcOutcome {
1406 NotNeeded,
1408 AlreadyRunning(GcLockHolder),
1410 DeclinedByHook {
1412 status: i32,
1414 output: crate::hooks::HookOutput,
1416 },
1417 Ran {
1419 reason: AutoGcReason,
1421 stats: Box<GcStats>,
1423 },
1424}
1425
1426pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1446 let Some(reason) = auto_gc_needed(repo)? else {
1447 return Ok(AutoGcOutcome::NotNeeded);
1448 };
1449 let now = gc_now_unix(opts.now_unix);
1450 let dry_run = opts.repack.dry_run;
1451 let _lock = if dry_run {
1452 None
1453 } else {
1454 match GcLock::try_acquire(repo, now)? {
1455 GcLockAttempt::Acquired(lock) => Some(lock),
1456 GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1457 }
1458 };
1459 if !dry_run {
1460 if let crate::hooks::HookResult::Failed { status, output } =
1461 crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1462 {
1463 return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1464 }
1465 }
1466 let mut run_opts = opts.clone();
1467 if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1468 run_opts.repack.incremental = true;
1469 run_opts.repack.write_bitmap = false;
1470 }
1471 let stats = gc_locked(repo, &run_opts, now)?;
1472 Ok(AutoGcOutcome::Ran {
1473 reason,
1474 stats: Box::new(stats),
1475 })
1476}
1477
1478#[derive(Debug, Clone, PartialEq, Eq)]
1480pub struct GcLockHolder {
1481 pub pid: Option<u64>,
1483 pub host: Option<String>,
1485}
1486
1487#[derive(Debug)]
1489#[must_use]
1490pub enum GcLockAttempt {
1491 Acquired(GcLock),
1493 Held(GcLockHolder),
1495}
1496
1497const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1499
1500#[derive(Debug)]
1506pub struct GcLock {
1507 path: PathBuf,
1508}
1509
1510impl GcLock {
1511 pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1519 let path = repo.common_git_dir().join("gc.pid");
1520 let mut lock = match crate::lockfile::LockFile::acquire(
1521 &path.with_extension("pid.lock"),
1522 crate::lockfile::LockWait::Immediately,
1523 ) {
1524 Ok(lock) => lock,
1525 Err(crate::lockfile::LockError::Held { .. }) => {
1526 return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1527 GcLockHolder {
1528 pid: None,
1529 host: None,
1530 },
1531 )));
1532 }
1533 Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1534 };
1535 let host = local_host_name();
1536 if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1537 lock.rollback();
1538 return Ok(GcLockAttempt::Held(holder));
1539 }
1540 use std::io::Write as _;
1541 write!(lock, "{} {host}", std::process::id())?;
1542 lock.commit_to(&path).map_err(|e| match e {
1543 crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1544 held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1545 })?;
1546 Ok(GcLockAttempt::Acquired(GcLock { path }))
1547 }
1548
1549 #[must_use]
1555 pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1556 let path = repo.common_git_dir().join("gc.pid");
1557 live_gc_pid_holder(&path, &local_host_name(), now_unix)
1558 }
1559}
1560
1561impl Drop for GcLock {
1562 fn drop(&mut self) {
1563 let _ = fs::remove_file(&self.path);
1564 }
1565}
1566
1567fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1568 let raw = fs::read_to_string(path).ok()?;
1569 let mut fields = raw.split_whitespace();
1570 let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1571 let host = fields.next().map(str::to_owned);
1572 Some(GcLockHolder { pid, host })
1573}
1574
1575fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1577 let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1578 let mtime = OffsetDateTime::from(mtime);
1579 let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1580 if now - mtime > GC_PID_STALE_AFTER {
1581 return None;
1582 }
1583 let holder = read_gc_pid(path)?;
1584 let pid = holder.pid?;
1585 let same_host = holder.host.as_deref() == Some(host);
1586 if same_host && !crate::lockfile::is_process_running(pid) {
1587 return None;
1588 }
1589 Some(holder)
1590}
1591
1592fn local_host_name() -> String {
1595 #[cfg(unix)]
1596 {
1597 if let Ok(uts) = nix::sys::utsname::uname() {
1598 if let Some(name) = uts.nodename().to_str() {
1599 return name.to_owned();
1600 }
1601 }
1602 }
1603 #[cfg(windows)]
1604 {
1605 if let Some(name) = windows_dns_host_name() {
1606 return name;
1607 }
1608 }
1609 "localhost".to_owned()
1610}
1611
1612#[cfg(windows)]
1614fn windows_dns_host_name() -> Option<String> {
1615 use windows_sys::Win32::System::SystemInformation::{
1616 ComputerNameDnsHostname, GetComputerNameExW,
1617 };
1618 let mut len: u32 = 0;
1619 unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1621 if len == 0 {
1622 return None;
1623 }
1624 let mut buf = vec![0u16; len as usize];
1625 let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1627 (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1628}
1629
1630fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
1631 let mut stats = GcStats::default();
1632 let cfg = repo.config()?;
1633 let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1634 let dry_run = opts.repack.dry_run;
1635
1636 if opts.pack_refs && !dry_run {
1637 let start = Instant::now();
1638 stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1639 stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1640 }
1641
1642 if opts.expire_reflog {
1643 let start = Instant::now();
1644 stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
1645 stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
1646 }
1647
1648 let mut repack_opts = opts.repack.clone();
1649 apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
1650 if repack_opts.now.is_none() {
1651 repack_opts.now =
1652 Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
1653 }
1654 if !repack_opts.write_bitmap && !repack_opts.incremental {
1657 repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg);
1658 }
1659 let repack_start = Instant::now();
1660 stats.repack = repack_all(repo, &repack_opts)?;
1661 let repack_total_ms = duration_ms(repack_start.elapsed());
1662 stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
1663 stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
1664
1665 if dry_run {
1666 return Ok(stats);
1667 }
1668
1669 stats.prune_ran = stats.repack.unreachable_prune_ran;
1672 stats.prune = PruneStats {
1673 pruned: stats.repack.unreachable_loose_pruned,
1674 kept: stats.repack.unreachable_loose_kept,
1675 };
1676
1677 if opts.write_commit_graph {
1678 let start = Instant::now();
1679 stats.commit_graph = Some(write_commit_graph(
1680 repo,
1681 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
1682 )?);
1683 stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
1684 }
1685 Ok(stats)
1686}
1687
1688enum GcLoosePrunePolicy {
1690 Disabled,
1692 Enabled {
1694 keep_newer_than: Option<SystemTime>,
1696 },
1697}
1698
1699fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
1700 let spec = config
1701 .get("gc.pruneExpire")
1702 .map(|s| s.trim().to_owned())
1703 .filter(|s| !s.is_empty())
1704 .unwrap_or_else(|| "2.weeks.ago".to_owned());
1705 if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
1706 return Ok(GcLoosePrunePolicy::Disabled);
1707 }
1708 if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
1710 return Ok(GcLoosePrunePolicy::Enabled {
1711 keep_newer_than: None,
1712 });
1713 }
1714 let mut err = 0;
1715 let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
1716 if err != 0 {
1717 return Err(Error::Config(ConfigError::Other(format!(
1718 "bad config value '{spec}' for 'gc.pruneExpire'"
1719 ))));
1720 }
1721 let keep_newer_than = SystemTime::UNIX_EPOCH
1722 .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
1723 Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
1724}
1725
1726fn gc_now_unix(now_unix: i64) -> i64 {
1727 if now_unix != 0 {
1728 return now_unix;
1729 }
1730 time::OffsetDateTime::now_utc().unix_timestamp()
1731}
1732
1733fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
1734 let cfg = repo.config()?;
1735 let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
1736 let params = ReflogExpireParams {
1737 stale_fix: false,
1738 dry_run,
1739 verbose: false,
1740 };
1741 let mut total = 0usize;
1742 let promises = std::cell::OnceCell::new();
1743 for refname in reflog_refs_on_store(repo.refs())? {
1744 let result = expire_reflog_git_with_promises(
1745 repo,
1746 &refname,
1747 ¶ms,
1748 None,
1749 None,
1750 &gc_cfg.patterns,
1751 gc_cfg.global_total,
1752 gc_cfg.global_unreachable,
1753 now,
1754 &promises,
1755 )?;
1756 total += result.pruned;
1757 }
1758 Ok(total)
1759}
1760
1761fn count_loose_objects(odb: &Odb) -> Result<usize> {
1762 Ok(odb.enumerate_local_loose_objects()?.len())
1763}
1764
1765#[allow(clippy::too_many_arguments)]
1768fn build_repack_pack(
1769 odb: &Odb,
1770 pack_dir: &Path,
1771 roots: &[ObjectId],
1772 shallow: &HashSet<ObjectId>,
1773 keep_unreachable: bool,
1774 opts: &PackBuildOptions,
1775 threads: Option<usize>,
1776 cfg: Option<&ConfigSet>,
1777 promised: &HashSet<ObjectId>,
1778) -> Result<SerializedDeltaPack> {
1779 let selection = repack_object_list(
1780 odb,
1781 roots,
1782 shallow,
1783 keep_unreachable,
1784 opts.use_bitmaps,
1785 promised,
1786 )?;
1787 write_staged_repack_pack(
1788 odb,
1789 pack_dir,
1790 &selection.send,
1791 &selection.name_hashes,
1792 opts,
1793 threads,
1794 cfg,
1795 )
1796}
1797
1798fn write_staged_repack_pack(
1811 odb: &Odb,
1812 pack_dir: &Path,
1813 objects: &[ObjectId],
1814 name_hashes: &HashMap<ObjectId, u32>,
1815 opts: &PackBuildOptions,
1816 threads: Option<usize>,
1817 cfg: Option<&ConfigSet>,
1818) -> Result<SerializedDeltaPack> {
1819 if objects.is_empty() {
1820 return Ok(SerializedDeltaPack {
1821 bytes: Vec::new(),
1822 index_rows: Vec::new(),
1823 staging_path: None,
1824 });
1825 }
1826 odb.clear_delta_base_cache();
1827 let max_depth = if opts.delta { opts.max_depth } else { 0 };
1828 let settings = RepackPackSettings {
1829 window: opts.window,
1830 max_depth,
1831 reuse_deltas: opts.reuse_deltas,
1832 reuse_objects: opts.reuse_objects,
1833 use_ofs_delta: opts.use_ofs_delta,
1834 window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
1835 search_threads: pack_delta_search_parallel_options(odb, threads)?
1836 .threads
1837 .get(),
1838 write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
1839 };
1840 let git_dir = odb
1841 .config_git_dir()
1842 .map(Path::to_path_buf)
1843 .unwrap_or_else(|| {
1844 pack_dir
1845 .parent()
1846 .and_then(|objects| objects.parent())
1847 .map(Path::to_path_buf)
1848 .unwrap_or_else(|| pack_dir.to_path_buf())
1849 });
1850 crate::git_dir::io_error::io_with_git_path(
1851 &git_dir,
1852 pack_dir,
1853 crate::git_dir::GitDirPathOperation::GcPack,
1854 fs::create_dir_all(pack_dir),
1855 )?;
1856 let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
1857 let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
1858 match written {
1859 Ok(index_rows) => Ok(SerializedDeltaPack {
1860 bytes: Vec::new(),
1861 index_rows,
1862 staging_path: Some(staging),
1863 }),
1864 Err(err) => {
1865 let _ = fs::remove_file(&staging);
1866 Err(err)
1867 }
1868 }
1869}
1870
1871fn repack_object_list(
1875 odb: &Odb,
1876 roots: &[ObjectId],
1877 shallow: &HashSet<ObjectId>,
1878 keep_unreachable: bool,
1879 use_bitmaps: bool,
1880 promised: &HashSet<ObjectId>,
1881) -> Result<PackObjectSelection> {
1882 let mut roots: Vec<_> = roots
1885 .iter()
1886 .copied()
1887 .filter(|oid| !promised.contains(oid))
1888 .collect();
1889 roots.sort_unstable();
1890 let mut selection = enumerate_pack_objects_naming(
1891 odb,
1892 &roots,
1893 &[],
1894 &PackEnumerateOptions {
1895 filter: None,
1896 shallow_grafts: shallow,
1897 have_shallow_grafts: Some(shallow),
1898 use_bitmaps: use_bitmaps && promised.is_empty(),
1899 exclude_objects: Some(promised),
1900 peer_odb: None,
1901 have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
1902 },
1903 NameTrees::Yes,
1904 )?;
1905 if keep_unreachable {
1906 append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
1907 }
1908 Ok(selection)
1909}
1910
1911fn append_repack_unreachable_retention(
1913 odb: &Odb,
1914 send: &mut Vec<ObjectId>,
1915 promised: &HashSet<ObjectId>,
1916) -> Result<()> {
1917 let mut in_send: OidHashSet = send.iter().copied().collect();
1918 odb.primary()?
1919 .packed_objects()
1920 .for_each_removable_pack_object(&mut |oid| {
1921 if !promised.contains(oid) && in_send.insert(*oid) {
1922 send.push(*oid);
1923 }
1924 std::ops::ControlFlow::Continue(())
1925 })?;
1926 for (oid, _) in odb.enumerate_local_loose_objects()? {
1927 if !promised.contains(&oid) && in_send.insert(oid) {
1928 send.push(oid);
1929 }
1930 }
1931 Ok(())
1932}
1933
1934fn count_pack_files(pack_dir: &Path) -> usize {
1936 let Ok(entries) = fs::read_dir(pack_dir) else {
1937 return 0;
1938 };
1939 let mut packs = HashSet::new();
1940 let mut kept = HashSet::new();
1941 for entry in entries.filter_map(|e| e.ok()) {
1942 let path = PathBuf::from(entry.file_name());
1943 let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
1944 continue;
1945 };
1946 if ext == "pack" {
1947 packs.insert(stem.to_owned());
1948 } else if ext == "keep" {
1949 kept.insert(stem.to_owned());
1950 }
1951 }
1952 packs.difference(&kept).count()
1953}
1954
1955fn packs_holding_available_promises(
1959 odb: &Odb,
1960 promised: &HashSet<ObjectId>,
1961) -> Result<HashSet<PathBuf>> {
1962 if promised.is_empty() {
1963 return Ok(HashSet::new());
1964 }
1965 let indexes = odb.local_pack_indexes()?;
1966 let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
1967 let mut retained = HashSet::new();
1968 for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
1969 for entry in idx.iter() {
1970 let oid = ObjectId::from_bytes(entry.oid())?;
1971 if promised.contains(&oid)
1972 && !promisor_indexes
1973 .iter()
1974 .any(|idx| idx.find_position(&oid).is_some())
1975 {
1976 retained.insert(idx.pack_path.clone());
1977 break;
1978 }
1979 }
1980 }
1981 Ok(retained)
1982}
1983
1984fn remove_superseded_pack_files_retaining(
1987 pack_dir: &Path,
1988 keep_pack: &Path,
1989 retained_packs: &HashSet<PathBuf>,
1990) -> Result<usize> {
1991 let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
1992 let keep_stem = keep_pack
1993 .file_stem()
1994 .and_then(|s| s.to_str())
1995 .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
1996 if !pack_dir.is_dir() {
1997 return Ok(0);
1998 }
1999 let mut removed = 0usize;
2000 for entry in crate::git_dir::io_error::io_with_git_path(
2001 &git_dir,
2002 pack_dir,
2003 crate::git_dir::GitDirPathOperation::GcPack,
2004 fs::read_dir(pack_dir),
2005 )? {
2006 let entry = entry.map_err(|source| {
2007 crate::git_dir::io_error::map_git_dir_io(
2008 &git_dir,
2009 pack_dir,
2010 crate::git_dir::GitDirPathOperation::GcPack,
2011 source,
2012 )
2013 })?;
2014 let name = entry.file_name();
2015 let name_str = name.to_string_lossy();
2016 if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2017 continue;
2018 }
2019 if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2020 continue;
2021 }
2022 let path = entry.path();
2023 if path.is_file() {
2024 if pack_has_keep_marker(pack_dir, &path)
2025 || path.with_extension("promisor").is_file()
2026 || retained_packs.contains(&path.with_extension("pack"))
2027 {
2028 continue;
2029 }
2030 crate::git_dir::io_error::io_with_git_path(
2031 &git_dir,
2032 &path,
2033 crate::git_dir::GitDirPathOperation::GcPack,
2034 fs::remove_file(&path),
2035 )?;
2036 removed += 1;
2037 }
2038 }
2039 Ok(removed)
2040}
2041
2042fn pack_has_keep_marker(pack_dir: &Path, path: &Path) -> bool {
2043 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
2044 return false;
2045 };
2046 pack_dir.join(format!("{stem}.keep")).is_file()
2047}
2048
2049pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2055 let (path, refs_packed, refs_pruned) =
2056 crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2057 Ok(PackRefsReport {
2058 path,
2059 refs_packed,
2060 refs_pruned,
2061 })
2062}
2063
2064impl Repository {
2065 pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2071 write_commit_graph(self, opts)
2072 }
2073
2074 pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2080 write_midx(self, opts)
2081 }
2082}
2083
2084pub mod fsck {
2086 pub use crate::fsck_msg_policy::{
2087 FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2088 };
2089 pub use crate::fsck_standalone::*;
2090}
2091
2092pub mod repository_fsck {
2094 pub use crate::fsck::*;
2095}
2096
2097#[cfg(test)]
2098mod tests {
2099 #![allow(clippy::unwrap_used, clippy::expect_used)]
2100
2101 use super::*;
2102 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2103 use crate::porcelain::add::stage_for_commit;
2104 use crate::porcelain::commit::{create_commit, CommitRequest};
2105 use crate::progress::{NeverCancel, NullProgress};
2106 use crate::repo::InitOptions;
2107 use tempfile::TempDir;
2108
2109 #[cfg_attr(
2110 not(feature = "full-tests"),
2111 ignore = "starts system git or is heavy; run with --features full-tests"
2112 )]
2113 #[test]
2114 fn gc_auto_skip_single_pack_without_loose() {
2115 let dir = TempDir::new().unwrap();
2116 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2117 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2118 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2119 let ident = "Test <t@example.com> 1 +0000".to_string();
2120 create_commit(
2121 &repo,
2122 &CommitRequest {
2123 message: "init".into(),
2124 author: Some(ident.parse().expect("signature")),
2125 committer: Some(ident.parse().expect("signature")),
2126 allow_empty: false,
2127 sign_override: None,
2128 amend: false,
2129 assume_worktree_synced: false,
2130 skip_pre_commit_tree_match: false,
2131 staged_change_count: None,
2132 ..Default::default()
2133 },
2134 &mut NullProgress,
2135 &NeverCancel,
2136 )
2137 .expect("commit");
2138 git_cmd(dir.path(), &["repack", "-a", "-d"]);
2139 assert!(should_auto_skip_repack(0, 1, false, true, None));
2140 assert!(!should_auto_skip_repack(3, 1, false, true, None));
2141 assert!(!should_auto_skip_repack(0, 2, false, true, None));
2142 assert!(!should_auto_skip_repack(0, 2, false, false, None));
2143 }
2144
2145 #[test]
2146 fn count_pack_files_ignores_keep_markers() {
2147 let dir = TempDir::new().unwrap();
2148 let pack_dir = dir.path().join("pack");
2149 fs::create_dir_all(&pack_dir).unwrap();
2150 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2151 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2152 fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
2153 assert_eq!(count_pack_files(&pack_dir), 1);
2154 }
2155
2156 fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
2157 let dir = TempDir::new().unwrap();
2158 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2159 let config_path = repo.git_dir().join("config");
2160 let mut config = fs::read_to_string(&config_path).unwrap();
2161 config.push_str(snippet);
2162 fs::write(&config_path, config).unwrap();
2163 repo.reload_config().expect("reload config");
2164 (dir, repo)
2165 }
2166
2167 fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
2168 let dir = repo.git_dir().join("objects").join(fanout);
2169 fs::create_dir_all(&dir).unwrap();
2170 for name in names {
2171 fs::write(dir.join(name), b"").unwrap();
2172 }
2173 }
2174
2175 const NAME_A: &str = "00000000000000000000000000000000000001";
2176 const NAME_B: &str = "00000000000000000000000000000000000002";
2177 const NAME_C: &str = "00000000000000000000000000000000000003";
2178
2179 #[test]
2180 fn auto_gc_needed_samples_one_fanout_directory() {
2181 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
2183 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2184 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2185 fake_loose(&repo, "18", &[NAME_C]);
2186 fake_loose(
2187 &repo,
2188 "17",
2189 &["tmp_obj_123", "0000000000000000000000000000000000000z"],
2190 );
2191 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2192 fake_loose(&repo, "17", &[NAME_C]);
2193 assert_eq!(
2194 auto_gc_needed(&repo).unwrap(),
2195 Some(AutoGcReason::TooManyLoose {
2196 estimate: 3 * 256,
2197 limit: 300
2198 })
2199 );
2200 }
2201
2202 #[test]
2203 fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
2204 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
2205 fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
2206 let pack_dir = repo.git_dir().join("objects/pack");
2207 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2208 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2209 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2210 assert_eq!(
2211 gc_auto(&repo, &GcOptions::default()).unwrap(),
2212 AutoGcOutcome::NotNeeded
2213 );
2214 }
2215
2216 #[test]
2217 fn auto_gc_needed_counts_packs_without_keep_markers() {
2218 let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
2219 let pack_dir = repo.git_dir().join("objects/pack");
2220 for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
2221 fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
2222 }
2223 assert_eq!(
2224 auto_gc_needed(&repo).unwrap(),
2225 Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
2226 );
2227 fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
2228 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2229 }
2230
2231 fn config_snippet(text: &str) -> ConfigSet {
2232 let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
2233 let mut set = ConfigSet::new();
2234 set.merge(&file);
2235 set
2236 }
2237
2238 #[test]
2239 fn prune_expire_now_and_all_have_no_grace_window() {
2240 let now = 1_700_000_000;
2241 for spec in ["now", "all", "NOW"] {
2242 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2243 assert!(matches!(
2244 gc_loose_prune_policy(&cfg, now).unwrap(),
2245 GcLoosePrunePolicy::Enabled {
2246 keep_newer_than: None
2247 }
2248 ));
2249 }
2250 for spec in ["never", "false"] {
2251 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2252 assert!(matches!(
2253 gc_loose_prune_policy(&cfg, now).unwrap(),
2254 GcLoosePrunePolicy::Disabled
2255 ));
2256 }
2257 let cfg = ConfigSet::new();
2258 assert!(matches!(
2259 gc_loose_prune_policy(&cfg, now).unwrap(),
2260 GcLoosePrunePolicy::Enabled {
2261 keep_newer_than: Some(_)
2262 }
2263 ));
2264 }
2265
2266 #[test]
2267 fn gc_lock_is_exclusive_and_released_on_drop() {
2268 let (_dir, repo) = repo_with_config("");
2269 let now = OffsetDateTime::now_utc().unix_timestamp();
2270 let pid_path = repo.git_dir().join("gc.pid");
2271 let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
2272 panic!("first acquire must succeed");
2273 };
2274 let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
2275 assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
2276 assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
2277 match GcLock::try_acquire(&repo, now).unwrap() {
2278 GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
2279 GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
2280 }
2281 assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
2282 assert!(matches!(
2283 gc(&repo, &GcOptions::default()),
2284 Err(Error::MaintenanceRunning { .. })
2285 ));
2286 assert!(!repo.git_dir().join("gc.pid.lock").exists());
2287 drop(lock);
2288 assert!(!pid_path.exists());
2289 assert_eq!(GcLock::current_holder(&repo, now), None);
2290 assert!(matches!(
2291 GcLock::try_acquire(&repo, now).unwrap(),
2292 GcLockAttempt::Acquired(_)
2293 ));
2294 }
2295
2296 #[test]
2297 fn gc_lock_takes_over_stale_entries() {
2298 let (_dir, repo) = repo_with_config("");
2299 let now = OffsetDateTime::now_utc().unix_timestamp();
2300 let pid_path = repo.git_dir().join("gc.pid");
2301 let host = local_host_name();
2302
2303 fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
2305 assert!(matches!(
2306 GcLock::try_acquire(&repo, now).unwrap(),
2307 GcLockAttempt::Acquired(_)
2308 ));
2309
2310 fs::write(&pid_path, "4242 some-other-host").unwrap();
2312 assert!(matches!(
2313 GcLock::try_acquire(&repo, now).unwrap(),
2314 GcLockAttempt::Held(GcLockHolder {
2315 pid: Some(4242),
2316 ..
2317 })
2318 ));
2319
2320 let later = now + time::Duration::hours(13).whole_seconds();
2322 assert!(matches!(
2323 GcLock::try_acquire(&repo, later).unwrap(),
2324 GcLockAttempt::Acquired(_)
2325 ));
2326 }
2327
2328 #[test]
2329 fn gc_auto_reports_held_lock_without_running() {
2330 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
2331 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2332 fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
2333 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
2334 assert!(
2335 matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
2336 "{outcome:?}"
2337 );
2338 assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
2339 }
2340
2341 #[cfg(unix)]
2342 #[test]
2343 fn gc_auto_honors_failing_pre_auto_gc_hook() {
2344 use std::os::unix::fs::PermissionsExt as _;
2345 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
2346 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2347 let hook = repo.git_dir().join("hooks/pre-auto-gc");
2348 fs::create_dir_all(hook.parent().unwrap()).unwrap();
2349 fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
2350 fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
2351 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
2352 assert!(
2353 matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
2354 "{outcome:?}"
2355 );
2356 assert!(!repo.git_dir().join("gc.pid").exists());
2357 }
2358
2359 fn git_cmd(dir: &Path, args: &[&str]) {
2360 let status = std::process::Command::new("git")
2361 .current_dir(dir)
2362 .args(args)
2363 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2364 .env("GIT_CONFIG_SYSTEM", "/dev/null")
2365 .status()
2366 .expect("git");
2367 assert!(status.success(), "git {args:?}");
2368 }
2369
2370 fn assert_fsck_clean(repo: &Repository) {
2371 let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
2372 let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
2373 assert!(
2374 report.ok(&repo.config().expect("config"), &opts),
2375 "{:?}",
2376 report.issues
2377 );
2378 }
2379
2380 #[test]
2381 fn fsck_clean_repo_ok() {
2382 let dir = TempDir::new().unwrap();
2383 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2384 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2385 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2386 let ident = "Test <t@example.com> 1 +0000".to_string();
2387 create_commit(
2388 &repo,
2389 &CommitRequest {
2390 message: "init".into(),
2391 author: Some(ident.parse().expect("signature")),
2392 committer: Some(ident.parse().expect("signature")),
2393 allow_empty: false,
2394 sign_override: None,
2395 amend: false,
2396 assume_worktree_synced: false,
2397 skip_pre_commit_tree_match: false,
2398 staged_change_count: None,
2399 ..Default::default()
2400 },
2401 &mut NullProgress,
2402 &NeverCancel,
2403 )
2404 .expect("commit");
2405 assert_fsck_clean(&repo);
2406 }
2407
2408 #[test]
2409 fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
2410 let dir = TempDir::new().unwrap();
2411 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2412 let wt = repo.work_tree().unwrap();
2413 std::fs::write(wt.join("base"), b"v1").unwrap();
2414 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2415 let ident = "Test <t@example.com> 1 +0000".to_string();
2416 create_commit(
2417 &repo,
2418 &CommitRequest {
2419 message: "base".into(),
2420 author: Some(ident.parse().expect("signature")),
2421 committer: Some(ident.parse().expect("signature")),
2422 allow_empty: false,
2423 sign_override: None,
2424 amend: false,
2425 assume_worktree_synced: false,
2426 skip_pre_commit_tree_match: false,
2427 staged_change_count: None,
2428 ..Default::default()
2429 },
2430 &mut NullProgress,
2431 &NeverCancel,
2432 )
2433 .expect("commit");
2434 repack_all(
2435 &repo,
2436 &RepackOptions {
2437 keep_unreachable: false,
2438 ..RepackOptions::default()
2439 },
2440 )
2441 .expect("repack base");
2442 let pack_dir = repo.git_dir().join("objects/pack");
2443 assert_eq!(count_pack_files(&pack_dir), 1);
2444
2445 std::fs::write(wt.join("child"), b"v2").unwrap();
2446 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
2447 create_commit(
2448 &repo,
2449 &CommitRequest {
2450 message: "child".into(),
2451 author: Some(ident.parse().expect("signature")),
2452 committer: Some(ident.parse().expect("signature")),
2453 allow_empty: false,
2454 sign_override: None,
2455 amend: false,
2456 assume_worktree_synced: false,
2457 skip_pre_commit_tree_match: false,
2458 staged_change_count: None,
2459 ..Default::default()
2460 },
2461 &mut NullProgress,
2462 &NeverCancel,
2463 )
2464 .expect("child commit");
2465 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
2466
2467 let packs_before = count_pack_files(&pack_dir);
2468 repack_all(
2469 &repo,
2470 &RepackOptions {
2471 incremental: true,
2472 write_bitmap: true,
2473 ..RepackOptions::default()
2474 },
2475 )
2476 .expect("incremental repack with bitmap must not fail after installing a pack");
2477
2478 assert!(
2479 count_pack_files(&pack_dir) > packs_before,
2480 "incremental repack should add a pack"
2481 );
2482 let midx = pack_dir.join("multi-pack-index");
2483 assert!(
2484 midx.is_file(),
2485 "expected multi-pack-index after bitmap repack"
2486 );
2487 let midx_bytes = fs::read(&midx).expect("read midx");
2488 assert_eq!(
2489 midx_bytes.get(4),
2490 Some(&1),
2491 "MIDX on-disk version must be v1 for system git compatibility"
2492 );
2493 let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
2494 .unwrap()
2495 .filter_map(|e| e.ok())
2496 .map(|e| e.path())
2497 .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
2498 .collect();
2499 assert!(
2500 !bitmaps.is_empty(),
2501 "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
2502 );
2503
2504 assert_fsck_clean(&repo);
2505 }
2506
2507 #[test]
2508 fn incremental_repack_adds_pack_without_removing_base() {
2509 let dir = TempDir::new().unwrap();
2510 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2511 let wt = repo.work_tree().unwrap();
2512 std::fs::write(wt.join("base"), b"v1").unwrap();
2513 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2514 let ident = "Test <t@example.com> 1 +0000".to_string();
2515 create_commit(
2516 &repo,
2517 &CommitRequest {
2518 message: "base".into(),
2519 author: Some(ident.parse().expect("signature")),
2520 committer: Some(ident.parse().expect("signature")),
2521 allow_empty: false,
2522 sign_override: None,
2523 amend: false,
2524 assume_worktree_synced: false,
2525 skip_pre_commit_tree_match: false,
2526 staged_change_count: None,
2527 ..Default::default()
2528 },
2529 &mut NullProgress,
2530 &NeverCancel,
2531 )
2532 .expect("commit");
2533 repack_all(
2534 &repo,
2535 &RepackOptions {
2536 keep_unreachable: false,
2537 ..RepackOptions::default()
2538 },
2539 )
2540 .expect("repack");
2541 let pack_dir = repo.git_dir().join("objects/pack");
2542 let packs_before = count_pack_files(&pack_dir);
2543 assert_eq!(packs_before, 1);
2544 for i in 0..5 {
2545 create_commit(
2546 &repo,
2547 &CommitRequest {
2548 message: format!("empty-{i}"),
2549 author: Some(ident.parse().expect("signature")),
2550 committer: Some(ident.parse().expect("signature")),
2551 allow_empty: true,
2552 sign_override: None,
2553 amend: false,
2554 assume_worktree_synced: false,
2555 skip_pre_commit_tree_match: false,
2556 staged_change_count: None,
2557 ..Default::default()
2558 },
2559 &mut NullProgress,
2560 &NeverCancel,
2561 )
2562 .expect("empty");
2563 }
2564 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
2565 repack_all(
2566 &repo,
2567 &RepackOptions {
2568 incremental: true,
2569 write_bitmap: false,
2570 ..RepackOptions::default()
2571 },
2572 )
2573 .expect("incremental repack");
2574 assert!(
2575 count_pack_files(&pack_dir) >= 2,
2576 "incremental repack should add a pack, not replace the base"
2577 );
2578 }
2579
2580 #[test]
2581 fn gc_full_run_records_per_phase_timings() {
2582 let dir = TempDir::new().unwrap();
2583 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2584 let wt = repo.work_tree().unwrap();
2585 for i in 0..5 {
2586 std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
2587 stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2588 let ident = "Test <t@example.com> 1 +0000".to_string();
2589 create_commit(
2590 &repo,
2591 &CommitRequest {
2592 message: format!("c{i}"),
2593 author: Some(ident.parse().expect("signature")),
2594 committer: Some(ident.parse().expect("signature")),
2595 allow_empty: false,
2596 sign_override: None,
2597 amend: false,
2598 assume_worktree_synced: false,
2599 skip_pre_commit_tree_match: false,
2600 staged_change_count: None,
2601 ..Default::default()
2602 },
2603 &mut NullProgress,
2604 &NeverCancel,
2605 )
2606 .expect("commit");
2607 }
2608 let stats = gc(
2609 &repo,
2610 &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
2611 )
2612 .expect("gc");
2613 let t = stats.phase_timings;
2614 assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
2615 assert!(
2616 t.repack_ms > 0,
2617 "repack phase must be timed separately from full gc"
2618 );
2619 assert!(stats.pack_refs.is_some());
2620 assert!(stats.commit_graph.is_some());
2621 assert_eq!(
2622 t.prune_ms, stats.repack.unreachable_prune_ms,
2623 "prune_ms must track unreachable loose prune inside repack, not repack_ms"
2624 );
2625 assert!(
2628 t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
2629 "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
2630 );
2631 assert!(stats.prune_ran, "full gc should run unreachable prune");
2632 }
2633}