1use std::collections::{HashMap, HashSet, VecDeque};
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};
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, parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind, OidHashSet,
44};
45use crate::odb::Odb;
46use crate::odb::UnreachablePackRetention;
47use crate::pack_bitmap::{PackBitmapWriteOptions, PackBitmapWriter};
48use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
49use crate::pack_object_select::{
50 enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
51};
52use crate::pack_objects::{
53 build_pack_from_send_list_with_parallel_threads, PackBuildOptions, SerializedDeltaPack,
54};
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, list_reflog_refs, load_gc_reflog_expire_config, ReflogExpireParams,
59};
60use crate::repack_pack::{write_repack_pack, RepackPackSettings};
61use crate::repo::Repository;
62use crate::shallow::load_shallow_boundaries;
63use time::OffsetDateTime;
64
65#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct RepackOptions {
69 pub dry_run: bool,
71 pub write_commit_graph: bool,
73 pub write_bitmap: bool,
75 pub prune_older_than: Option<OffsetDateTime>,
77 pub now: Option<OffsetDateTime>,
79 pub keep_unreachable: bool,
81 pub threads: Option<usize>,
83 pub pack_build: Option<PackBuildOptions>,
90 pub merged_config: Option<ConfigSet>,
92 pub environment: Environment,
94 pub incremental: bool,
100 pub auto_skip: bool,
104 pub force_recompress: bool,
108}
109
110impl Default for RepackOptions {
111 fn default() -> Self {
112 Self {
113 dry_run: false,
114 write_commit_graph: false,
115 write_bitmap: false,
116 prune_older_than: None,
117 now: None,
118 keep_unreachable: true,
119 threads: None,
120 pack_build: None,
121 merged_config: None,
122 environment: Environment::empty(),
123 incremental: false,
124 auto_skip: false,
125 force_recompress: false,
126 }
127 }
128}
129
130impl RepackOptions {
131 #[must_use]
133 pub fn with_dry_run(dry_run: bool) -> Self {
134 Self {
135 dry_run,
136 ..Self::default()
137 }
138 }
139}
140
141struct RepackDetail {
142 report: RepackReport,
143 prune_packed_loose: usize,
144 unreachable_loose: PruneStats,
145 unreachable_prune_ms: u64,
146 unreachable_prune_ran: bool,
147}
148
149#[derive(Debug, Clone, Default, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct RepackReport {
153 pub packs_written: usize,
155 pub packs_removed: usize,
157 pub loose_removed: usize,
159 pub objects: usize,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
165#[non_exhaustive]
166pub struct RepackStats {
167 pub loose_before: usize,
169 pub packs_before: usize,
171 pub objects_packed: Option<usize>,
173 pub pack_bytes: Option<u64>,
175 pub pack_path: Option<PathBuf>,
177 pub removed_pack_files: usize,
179 pub pruned_loose: usize,
181 pub unreachable_loose_pruned: usize,
183 pub unreachable_loose_kept: usize,
185 pub unreachable_prune_ms: u64,
187 pub unreachable_prune_ran: bool,
189}
190
191#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
193#[non_exhaustive]
194pub struct CommitGraphOptions {
195 pub changed_paths: bool,
197 pub dry_run: bool,
199}
200
201impl CommitGraphOptions {
202 #[must_use]
204 pub fn with_changed_paths(changed_paths: bool) -> Self {
205 Self {
206 changed_paths,
207 ..Self::default()
208 }
209 }
210
211 #[must_use]
213 pub fn with_dry_run(dry_run: bool) -> Self {
214 Self {
215 dry_run,
216 ..Self::default()
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
223#[non_exhaustive]
224pub struct CommitGraphWriteReport {
225 pub path: PathBuf,
227 pub commit_count: usize,
229 pub removed_chain: bool,
231 pub bloom_stats: Option<BloomWriteStats>,
234 pub skipped: Option<CommitGraphSkipReason>,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum CommitGraphSkipReason {
246 Shallow,
248 Grafts,
250 ReplaceRefs,
252}
253
254impl CommitGraphSkipReason {
255 #[must_use]
257 pub fn as_str(self) -> &'static str {
258 match self {
259 Self::Shallow => "shallow",
260 Self::Grafts => "grafts",
261 Self::ReplaceRefs => "replace-refs",
262 }
263 }
264
265 pub(crate) fn detect(
271 git_dir: &Path,
272 has_replace_refs: impl FnOnce() -> Result<bool>,
273 ) -> Result<Option<Self>> {
274 let common = crate::repo::common_git_dir_for_config(git_dir);
275 if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
276 return Ok(Some(Self::Shallow));
277 }
278 if !crate::rev_parse::load_graft_parents(&common).is_empty() {
279 return Ok(Some(Self::Grafts));
280 }
281 Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
282 }
283
284 pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
287 Self::detect(repo.git_dir(), || {
288 let Some(base) = repo.replace_ref_base_if_enabled() else {
289 return Ok(false);
290 };
291 Ok(!repo.references().list(&base)?.is_empty())
292 })
293 }
294}
295
296#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
298#[non_exhaustive]
299pub struct MidxOptions {
300 pub bitmap: bool,
302 pub dry_run: bool,
304 pub version: Option<u8>,
308}
309
310impl MidxOptions {
311 #[must_use]
313 pub fn with_bitmap(bitmap: bool) -> Self {
314 Self {
315 bitmap,
316 ..Self::default()
317 }
318 }
319
320 #[must_use]
322 pub fn with_dry_run(dry_run: bool) -> Self {
323 Self {
324 dry_run,
325 ..Self::default()
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332#[non_exhaustive]
333pub struct MidxWriteReport {
334 pub path: PathBuf,
336 pub pack_count: usize,
338 pub bitmap: MidxBitmapWriteOutcome,
340}
341
342#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
344#[non_exhaustive]
345pub struct PackRefsOptions {
346 pub all: bool,
348 pub dry_run: bool,
350}
351
352impl PackRefsOptions {
353 #[must_use]
355 pub fn with_all(all: bool) -> Self {
356 Self {
357 all,
358 ..Self::default()
359 }
360 }
361
362 #[must_use]
364 pub fn with_dry_run(dry_run: bool) -> Self {
365 Self {
366 dry_run,
367 ..Self::default()
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
374#[non_exhaustive]
375pub struct PackRefsReport {
376 pub path: PathBuf,
378 pub refs_packed: usize,
380 pub refs_pruned: usize,
382}
383
384#[derive(Debug, Clone)]
386#[non_exhaustive]
387pub struct GcOptions {
388 pub repack: RepackOptions,
390 pub expire_reflog: bool,
392 pub now_unix: i64,
394 pub pack_refs: bool,
396 pub write_commit_graph: bool,
401}
402
403impl Default for GcOptions {
404 fn default() -> Self {
405 Self {
406 repack: RepackOptions {
407 auto_skip: true,
408 ..RepackOptions::default()
409 },
410 expire_reflog: true,
411 now_unix: 0,
412 pack_refs: true,
413 write_commit_graph: true,
414 }
415 }
416}
417
418impl GcOptions {
419 #[must_use]
421 pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
422 Self {
423 repack: RepackOptions {
424 dry_run,
425 auto_skip: true,
426 ..RepackOptions::default()
427 },
428 expire_reflog: true,
429 now_unix,
430 ..Self::default()
431 }
432 }
433
434 #[must_use]
436 pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
437 Self {
438 repack: RepackOptions {
439 auto_skip: true,
440 ..RepackOptions::default()
441 },
442 expire_reflog,
443 now_unix,
444 ..Self::default()
445 }
446 }
447
448 #[must_use]
450 pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
451 Self {
452 repack: RepackOptions {
453 dry_run,
454 auto_skip: true,
455 ..RepackOptions::default()
456 },
457 expire_reflog: true,
458 now_unix,
459 pack_refs: true,
460 write_commit_graph: true,
461 ..Self::default()
462 }
463 }
464}
465
466impl PruneOptions {
467 #[must_use]
469 pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
470 Self {
471 older_than,
472 ..Self::default()
473 }
474 }
475}
476
477pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
486 repo_fsck::reachability_roots(repo)
487}
488
489pub(crate) fn bloom_settings_for_commit_graph_write(
490 config: &ConfigSet,
491 changed_paths: bool,
492) -> BloomFilterSettings {
493 let mut bloom = BloomFilterSettings::default();
494 if !changed_paths {
495 return bloom;
496 }
497 if let Some(v) = config
498 .get("commitgraph.changedpathsversion")
499 .and_then(|s| s.trim().parse::<i32>().ok())
500 {
501 if v == 2 {
502 bloom.hash_version = 2;
503 } else if v == 1 {
504 bloom.hash_version = 1;
505 }
506 }
507 bloom
508}
509
510fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
516 repo.odb()
517 .files_objects_dir()
518 .and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
519 .is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
520}
521
522pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
523 config
524 .get("commitgraph.generationversion")
525 .and_then(|s| s.trim().parse::<i32>().ok())
526 .map(|v| v >= 2)
527 .unwrap_or(true)
528}
529
530pub fn write_commit_graph(
546 repo: &Repository,
547 opts: &CommitGraphOptions,
548) -> Result<CommitGraphWriteReport> {
549 let odb = repo.odb();
550 odb.require_files_primary("commit-graph write")?;
551 let objects_dir = odb
552 .files_objects_dir()
553 .ok_or(Error::UnsupportedObjectStore {
554 operation: "commit-graph write",
555 })?;
556 let path = objects_dir.join("info").join("commit-graph");
557
558 if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
559 let removed_chain = if opts.dry_run {
560 false
561 } else {
562 let removed = remove_commit_graph_files(objects_dir)?;
563 repo.caches().invalidate_commit_graph_cache();
564 removed
565 };
566 return Ok(CommitGraphWriteReport {
567 path,
568 commit_count: 0,
569 removed_chain,
570 bloom_stats: None,
571 skipped: Some(reason),
572 });
573 }
574
575 let config = repo.config()?;
576 let bloom = bloom_settings_for_commit_graph_write(&config, opts.changed_paths);
577 let write_generation = commit_graph_writes_generation_data(&config);
578
579 let chain = CommitGraphChain::try_load(objects_dir).ok().flatten();
580
581 let commits = collect_reachable_commit_oids_for_repository(repo)?;
582 let mut sorted: Vec<ObjectId> = commits.into_iter().collect();
583 sorted.sort();
584 let commit_count = sorted.len();
585
586 if opts.dry_run {
587 return Ok(CommitGraphWriteReport {
588 path,
589 commit_count,
590 removed_chain: false,
591 bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
592 skipped: None,
593 });
594 }
595
596 let mut infos = oid_hash_map();
597 for oid in &sorted {
598 infos.insert(*oid, load_commit_graph_commit_info(odb, *oid)?);
599 }
600
601 let mut existing_filters = oid_hash_map();
602 let mut upgraded_filters = oid_hash_map();
603 if opts.changed_paths {
604 if let Some(ref chain) = chain {
605 for oid in &sorted {
606 if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
607 existing_filters.insert(*oid, bytes);
608 } else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
609 upgraded_filters.insert(*oid, bytes);
610 }
611 }
612 }
613 }
614
615 let (bytes, bloom_stats) = build_commit_graph_bytes_from_oid_maps(
616 &sorted,
617 &infos,
618 odb,
619 opts.changed_paths,
620 &bloom,
621 None,
622 &[],
623 None,
624 &existing_filters,
625 &upgraded_filters,
626 write_generation,
627 )?;
628
629 let written_path = atomic_replace_commit_graph(objects_dir, &bytes)?;
630 let removed_chain = remove_commit_graph_chain_dir(objects_dir);
631 repo.caches().invalidate_commit_graph_cache();
632
633 Ok(CommitGraphWriteReport {
634 path: written_path,
635 commit_count,
636 removed_chain,
637 bloom_stats: opts.changed_paths.then_some(bloom_stats),
638 skipped: None,
639 })
640}
641
642pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
652 let odb = repo.odb();
653 odb.require_files_primary("midx write")?;
654 let requested = match opts.version {
655 Some(v) => Some(v),
656 None => midx_version_from_config(&*repo.config()?)?,
657 };
658 let version = Some(midx_write_version(requested)?);
659 let objects_dir = odb
660 .files_objects_dir()
661 .ok_or(Error::UnsupportedObjectStore {
662 operation: "midx write",
663 })?;
664 let pack_dir = objects_dir.join("pack");
665 let pack_count = fs::read_dir(&pack_dir)
666 .map(|rd| {
667 rd.filter_map(|e| e.ok())
668 .filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
669 .filter(|e| e.path().with_extension("pack").is_file())
670 .count()
671 })
672 .unwrap_or(0);
673
674 let path = pack_dir.join("multi-pack-index");
675 if opts.dry_run {
676 return Ok(MidxWriteReport {
677 path,
678 pack_count,
679 bitmap: MidxBitmapWriteOutcome::NotRequested,
680 });
681 }
682 let write_opts = WriteMultiPackIndexOptions {
683 write_bitmap: opts.bitmap,
684 version,
685 ..WriteMultiPackIndexOptions::default()
686 };
687 let WriteMultiPackIndexResult { bitmap } =
688 write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
689 Ok(MidxWriteReport {
690 path,
691 pack_count,
692 bitmap,
693 })
694}
695
696#[derive(Debug, Clone, Default)]
698#[non_exhaustive]
699pub struct PruneOptions {
700 pub older_than: Option<SystemTime>,
704 pub dry_run: bool,
706}
707
708#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
710#[non_exhaustive]
711pub struct GcPhaseTimings {
712 pub pack_refs_ms: u64,
714 pub reflog_ms: u64,
716 pub repack_ms: u64,
718 pub prune_ms: u64,
720 pub commit_graph_ms: u64,
722}
723
724#[derive(Debug, Clone, Default, PartialEq, Eq)]
726pub struct GcStats {
727 pub pack_refs: Option<PackRefsReport>,
729 pub repack: RepackStats,
731 pub prune: PruneStats,
733 pub prune_ran: bool,
735 pub commit_graph: Option<CommitGraphWriteReport>,
737 pub reflog_entries_pruned: usize,
739 pub phase_timings: GcPhaseTimings,
741}
742
743pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
753 Ok(repack_detail(repo, opts)?.report)
754}
755
756fn repack_detail(repo: &Repository, opts: &RepackOptions) -> Result<RepackDetail> {
757 let odb = repo.odb();
758 odb.require_files_primary("repack")?;
759 let objects_dir = odb
760 .files_objects_dir()
761 .ok_or(Error::UnsupportedObjectStore {
762 operation: "repack",
763 })?;
764 let pack_dir = objects_dir.join("pack");
765
766 let mut report = RepackReport::default();
767 let loose_before = count_loose_objects(odb)?;
768 let packs_before = count_pack_files(&pack_dir);
769
770 let roots = collect_maintenance_object_roots(repo)?;
771 if roots.is_empty() && loose_before == 0 && packs_before == 0 {
772 return Ok(RepackDetail {
773 report,
774 prune_packed_loose: 0,
775 unreachable_loose: PruneStats::default(),
776 unreachable_prune_ms: 0,
777 unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
778 });
779 }
780
781 let cfg = opts
782 .merged_config
783 .clone()
784 .or_else(|| repo.config().ok().map(|c| (*c).clone()));
785 let mut pack_opts = opts
786 .pack_build
787 .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
788 if opts.force_recompress {
789 pack_opts.reuse_deltas = false;
790 }
791
792 let run_incremental = opts.incremental;
793 let shallow = load_shallow_boundaries(repo.git_dir());
794 let reachable = LazyReachable::new(odb, &roots, &shallow);
795
796 let skip_repack = !opts.force_recompress
797 && !opts.write_bitmap
798 && !opts.write_commit_graph
799 && should_auto_skip_repack(
800 loose_before,
801 packs_before,
802 run_incremental,
803 opts.auto_skip,
804 cfg.as_ref(),
805 )
806 && !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
807
808 if opts.dry_run {
809 report.objects = if skip_repack {
810 0
811 } else if run_incremental {
812 reachable_loose_objects(odb, &reachable)?.len()
813 } else {
814 estimate_repack_object_count(
815 odb,
816 &roots,
817 &shallow,
818 opts.keep_unreachable,
819 pack_opts.use_bitmaps,
820 )?
821 };
822 return Ok(RepackDetail {
823 report,
824 prune_packed_loose: 0,
825 unreachable_loose: PruneStats::default(),
826 unreachable_prune_ms: 0,
827 unreachable_prune_ran: false,
828 });
829 }
830
831 let built_pack = if skip_repack {
832 SerializedDeltaPack {
833 bytes: Vec::new(),
834 index_rows: Vec::new(),
835 staging_path: None,
836 }
837 } else if run_incremental {
838 SerializedDeltaPack {
839 bytes: pack_loose_objects_incremental(odb, &reachable, &pack_opts, opts.threads)?,
840 index_rows: Vec::new(),
841 staging_path: None,
842 }
843 } else {
844 build_repack_pack(
845 odb,
846 &pack_dir,
847 &roots,
848 &shallow,
849 opts.keep_unreachable,
850 &pack_opts,
851 opts.threads,
852 cfg.as_ref(),
853 )?
854 };
855 let pack_bytes_len = built_pack
856 .staging_path
857 .as_ref()
858 .and_then(|p| std::fs::metadata(p).ok())
859 .map(|m| m.len() as usize)
860 .unwrap_or_else(|| built_pack.bytes.len());
861 if pack_bytes_len == 0 && !run_incremental {
864 return Ok(RepackDetail {
865 report,
866 prune_packed_loose: 0,
867 unreachable_loose: PruneStats::default(),
868 unreachable_prune_ms: 0,
869 unreachable_prune_ran: false,
870 });
871 }
872 let mut prune_packed_loose = 0usize;
873 let mut unreachable_loose = PruneStats::default();
874 let pack_trailer_len = 12 + odb.hash_algo().len();
875 if pack_bytes_len > pack_trailer_len {
876 clear_pack_midx_state(&pack_dir)?;
877
878 let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
879 let ingest_opts = IngestPackOptions {
880 fix_thin: false,
881 threads: opts.threads,
882 merged_config: cfg.clone(),
883 environment: opts.environment.clone(),
884 skip_post_index_verify: trusted_rows.is_some(),
886 trusted_index_rows: trusted_rows,
887 ..IngestPackOptions::default()
888 };
889 let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
890 install_pack_path(staging, odb, &ingest_opts)?
891 } else {
892 install_pack_bytes(built_pack.bytes, odb, &ingest_opts)?
893 };
894 report.packs_written = 1;
895 report.objects = ingested.object_ids.len();
896 let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
897 operation: "repack",
898 })?;
899
900 let idx_path = pack_path.with_extension("idx");
901 if cfg
902 .as_ref()
903 .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
904 {
905 write_pack_rev_sidecar_for_index_path(&idx_path)?;
906 }
907
908 if opts.write_bitmap {
909 if opts.incremental && packs_before > 0 {
910 let version = match cfg.as_ref() {
914 Some(c) => midx_version_from_config(c)?,
915 None => None,
916 };
917 let midx_opts = WriteMultiPackIndexOptions {
918 write_bitmap: true,
919 version,
920 ..WriteMultiPackIndexOptions::default()
921 };
922 let WriteMultiPackIndexResult { bitmap: _ } =
923 write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
924 } else {
925 let bitmap_opts = PackBitmapWriteOptions::default();
926 let now = opts
927 .now
928 .map(offset_to_system_time)
929 .unwrap_or(SystemTime::UNIX_EPOCH);
930 PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now)
931 .map_err(|e| Error::CorruptObject(e.to_string()))?;
932 }
933 }
934
935 let retention = if opts.keep_unreachable {
936 UnreachablePackRetention::KeepAll
937 } else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
938 UnreachablePackRetention::KeepGraceProtected(cutoff)
939 } else {
940 UnreachablePackRetention::DropAll
941 };
942 if !opts.incremental {
944 odb.loosen_unreachable_from_superseded_packs(
945 &pack_dir, &pack_path, &reachable, retention,
946 )?;
947 }
948
949 odb.invalidate_packs();
951
952 report.packs_removed = if opts.incremental {
953 0
954 } else {
955 remove_superseded_pack_files(&pack_dir, &pack_path)?
956 };
957
958 prune_packed_loose =
959 prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
960
961 if opts.write_commit_graph {
962 write_commit_graph(
963 repo,
964 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
965 repo,
966 )),
967 )?;
968 }
969 }
970
971 let mut unreachable_prune_ms = 0u64;
972 let mut unreachable_prune_ran = false;
973 if !opts.keep_unreachable {
974 let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
975 let prune_start = Instant::now();
976 unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
977 unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
978 unreachable_prune_ran = true;
979 }
980
981 report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
982 Ok(RepackDetail {
983 report,
984 prune_packed_loose,
985 unreachable_loose,
986 unreachable_prune_ms,
987 unreachable_prune_ran,
988 })
989}
990
991pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
993 let odb = repo.odb();
994 let objects_dir = odb
995 .files_objects_dir()
996 .ok_or(Error::UnsupportedObjectStore {
997 operation: "repack",
998 })?;
999 let pack_dir = objects_dir.join("pack");
1000 let mut stats = RepackStats {
1001 loose_before: count_loose_objects(odb)?,
1002 packs_before: count_pack_files(&pack_dir),
1003 ..RepackStats::default()
1004 };
1005 if opts.dry_run {
1006 let count = repack_detail(repo, opts)?.report.objects;
1007 stats.objects_packed = (count > 0).then_some(count);
1008 return Ok(stats);
1009 }
1010 let detail = repack_detail(repo, opts)?;
1011 let report = detail.report;
1012 stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
1013 stats.removed_pack_files = report.packs_removed;
1014 stats.pruned_loose = detail.prune_packed_loose;
1015 stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
1016 stats.unreachable_loose_kept = detail.unreachable_loose.kept;
1017 stats.unreachable_prune_ms = detail.unreachable_prune_ms;
1018 stats.unreachable_prune_ran = detail.unreachable_prune_ran;
1019 if report.packs_written > 0 {
1020 let packs: Vec<_> = fs::read_dir(&pack_dir)
1021 .ok()
1022 .into_iter()
1023 .flatten()
1024 .filter_map(|e| e.ok())
1025 .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
1026 .map(|e| e.path())
1027 .collect();
1028 if packs.len() == 1 {
1029 stats.pack_path = Some(packs[0].clone());
1030 stats.pack_bytes = packs[0].metadata().ok().map(|m| m.len());
1031 }
1032 }
1033 Ok(stats)
1034}
1035
1036fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
1037 SystemTime::UNIX_EPOCH
1038 + std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
1039}
1040
1041fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
1042 match policy {
1043 GcLoosePrunePolicy::Disabled => {
1044 repack.keep_unreachable = true;
1045 }
1046 GcLoosePrunePolicy::Enabled { keep_newer_than } => {
1047 repack.keep_unreachable = false;
1048 repack.prune_older_than = keep_newer_than.map(|t| {
1049 let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
1050 OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
1051 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
1052 });
1053 }
1054 }
1055}
1056
1057fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> bool {
1058 if let Some(Ok(v)) = config.get_bool("repack.writeBitmaps") {
1059 return v;
1060 }
1061 repo.work_tree().is_none()
1062}
1063
1064fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1067 let loose = odb.enumerate_local_loose_objects()?;
1068 if loose.is_empty() {
1069 return Ok(Vec::new());
1070 }
1071 let reachable = reachable.get()?;
1072 Ok(loose
1073 .into_iter()
1074 .map(|(oid, _)| oid)
1075 .filter(|oid| reachable.contains(oid))
1076 .collect())
1077}
1078
1079fn pack_loose_objects_incremental(
1080 odb: &Odb,
1081 reachable: &LazyReachable<'_>,
1082 pack_opts: &PackBuildOptions,
1083 threads: Option<usize>,
1084) -> Result<Vec<u8>> {
1085 let loose = reachable_loose_objects(odb, reachable)?;
1086 if loose.is_empty() {
1087 return Ok(Vec::new());
1088 }
1089 let mut incremental_opts = *pack_opts;
1090 incremental_opts.delta = false;
1091 incremental_opts.reuse_deltas = false;
1092 incremental_opts.use_bitmaps = false;
1093 build_pack_from_send_list_with_parallel_threads(
1094 odb,
1095 &loose,
1096 &HashSet::new(),
1097 &incremental_opts,
1098 &HashMap::new(),
1099 None,
1100 threads,
1101 )
1102}
1103
1104fn should_auto_skip_repack(
1109 loose_objects: usize,
1110 pack_count: usize,
1111 incremental: bool,
1112 auto_skip: bool,
1113 _config: Option<&ConfigSet>,
1114) -> bool {
1115 if !auto_skip || incremental {
1116 return false;
1117 }
1118 if pack_count > 1 {
1119 return false;
1120 }
1121 if pack_count == 1 {
1122 return loose_objects == 0;
1123 }
1124 loose_objects == 0
1125}
1126
1127fn skipping_repack_would_retain_prunable_packed_objects(
1132 odb: &Odb,
1133 pack_dir: &Path,
1134 reachable: &LazyReachable<'_>,
1135 opts: &RepackOptions,
1136) -> Result<bool> {
1137 if opts.keep_unreachable {
1138 return Ok(false);
1139 }
1140 let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1141 UnreachablePackRetention::KeepGraceProtected(cutoff)
1142 } else {
1143 UnreachablePackRetention::DropAll
1144 };
1145 let indexes = crate::pack::read_local_pack_indexes_cached(odb.objects_dir())?;
1146 for idx in indexes {
1147 if pack_has_keep_marker(pack_dir, &idx.pack_path) {
1148 continue;
1149 }
1150 let pack_mtime = fs::metadata(&idx.pack_path)
1151 .and_then(|m| m.modified())
1152 .unwrap_or(SystemTime::UNIX_EPOCH);
1153 let drop_unreachable_from_pack = match retention {
1154 UnreachablePackRetention::KeepAll => false,
1155 UnreachablePackRetention::DropAll => true,
1156 UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1157 };
1158 if !drop_unreachable_from_pack {
1159 continue;
1160 }
1161 let reachable = reachable.get()?;
1162 for entry in idx.iter() {
1163 let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1164 continue;
1165 };
1166 if !reachable.contains(&oid) {
1167 return Ok(true);
1168 }
1169 }
1170 }
1171 Ok(false)
1172}
1173
1174pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1180 let odb = repo.odb();
1181 odb.require_files_primary("prune")?;
1182 let cfg = repo.config()?;
1183 let now = gc_now_unix(0);
1184 let keep_newer_than = match opts.older_than {
1185 Some(t) => Some(t),
1186 None => match gc_loose_prune_policy(&cfg, now)? {
1187 GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1188 GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1189 },
1190 };
1191 let roots = collect_maintenance_object_roots(repo)?;
1192 let shallow = load_shallow_boundaries(repo.git_dir());
1193 prune_loose_unreachable_with(
1194 odb,
1195 &LazyReachable::new(odb, &roots, &shallow),
1196 keep_newer_than,
1197 opts.dry_run,
1198 )
1199}
1200
1201fn duration_ms(d: std::time::Duration) -> u64 {
1202 d.as_millis() as u64
1203}
1204
1205pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1215 repo.odb().require_files_primary("gc")?;
1216 let now = gc_now_unix(opts.now_unix);
1217 let _lock = if opts.repack.dry_run {
1218 None
1219 } else {
1220 match GcLock::try_acquire(repo, now)? {
1221 GcLockAttempt::Acquired(lock) => Some(lock),
1222 GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1223 }
1224 };
1225 gc_locked(repo, opts, now)
1226}
1227
1228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1230pub enum AutoGcReason {
1231 TooManyLoose {
1234 estimate: usize,
1236 limit: usize,
1238 },
1239 TooManyPacks {
1242 count: usize,
1244 limit: usize,
1246 },
1247}
1248
1249const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1251
1252pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1267 let cfg = repo.config()?;
1268 let Some(threshold) = cfg.gc_auto_threshold() else {
1269 return Ok(None);
1270 };
1271 let Some(objects_dir) = repo.odb().files_objects_dir() else {
1272 return Ok(None);
1273 };
1274 if let Some(limit) = cfg.gc_auto_pack_limit() {
1275 let count = count_pack_files(&objects_dir.join("pack"));
1276 if count > limit {
1277 return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1278 }
1279 }
1280 let name_len = repo.odb().hash_algo().hex_len() - 2;
1281 let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1282 if sampled > threshold.div_ceil(256) {
1283 return Ok(Some(AutoGcReason::TooManyLoose {
1284 estimate: sampled.saturating_mul(256),
1285 limit: threshold,
1286 }));
1287 }
1288 Ok(None)
1289}
1290
1291fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1293 let entries = match fs::read_dir(dir) {
1294 Ok(entries) => entries,
1295 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1296 Err(e) => return Err(Error::Io(e)),
1297 };
1298 Ok(entries
1299 .filter_map(|e| e.ok())
1300 .filter(|e| {
1301 let name = e.file_name();
1302 let name = name.as_encoded_bytes();
1303 name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1304 })
1305 .count())
1306}
1307
1308#[derive(Debug, Clone, PartialEq, Eq)]
1310#[non_exhaustive]
1311pub enum AutoGcOutcome {
1312 NotNeeded,
1314 AlreadyRunning(GcLockHolder),
1316 DeclinedByHook {
1318 status: i32,
1320 output: crate::hooks::HookOutput,
1322 },
1323 Ran {
1325 reason: AutoGcReason,
1327 stats: Box<GcStats>,
1329 },
1330}
1331
1332pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1352 let Some(reason) = auto_gc_needed(repo)? else {
1353 return Ok(AutoGcOutcome::NotNeeded);
1354 };
1355 let now = gc_now_unix(opts.now_unix);
1356 let dry_run = opts.repack.dry_run;
1357 let _lock = if dry_run {
1358 None
1359 } else {
1360 match GcLock::try_acquire(repo, now)? {
1361 GcLockAttempt::Acquired(lock) => Some(lock),
1362 GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1363 }
1364 };
1365 if !dry_run {
1366 if let crate::hooks::HookResult::Failed { status, output } =
1367 crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1368 {
1369 return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1370 }
1371 }
1372 let mut run_opts = opts.clone();
1373 if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1374 run_opts.repack.incremental = true;
1375 run_opts.repack.write_bitmap = false;
1376 }
1377 let stats = gc_locked(repo, &run_opts, now)?;
1378 Ok(AutoGcOutcome::Ran {
1379 reason,
1380 stats: Box::new(stats),
1381 })
1382}
1383
1384#[derive(Debug, Clone, PartialEq, Eq)]
1386pub struct GcLockHolder {
1387 pub pid: Option<u64>,
1389 pub host: Option<String>,
1391}
1392
1393#[derive(Debug)]
1395#[must_use]
1396pub enum GcLockAttempt {
1397 Acquired(GcLock),
1399 Held(GcLockHolder),
1401}
1402
1403const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1405
1406#[derive(Debug)]
1412pub struct GcLock {
1413 path: PathBuf,
1414}
1415
1416impl GcLock {
1417 pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1425 let path = repo.common_git_dir().join("gc.pid");
1426 let mut lock = match crate::lockfile::LockFile::acquire(
1427 &path.with_extension("pid.lock"),
1428 crate::lockfile::LockWait::Immediately,
1429 ) {
1430 Ok(lock) => lock,
1431 Err(crate::lockfile::LockError::Held { .. }) => {
1432 return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1433 GcLockHolder {
1434 pid: None,
1435 host: None,
1436 },
1437 )));
1438 }
1439 Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1440 };
1441 let host = local_host_name();
1442 if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1443 lock.rollback();
1444 return Ok(GcLockAttempt::Held(holder));
1445 }
1446 use std::io::Write as _;
1447 write!(lock, "{} {host}", std::process::id())?;
1448 lock.commit_to(&path).map_err(|e| match e {
1449 crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1450 held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1451 })?;
1452 Ok(GcLockAttempt::Acquired(GcLock { path }))
1453 }
1454
1455 #[must_use]
1461 pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1462 let path = repo.common_git_dir().join("gc.pid");
1463 live_gc_pid_holder(&path, &local_host_name(), now_unix)
1464 }
1465}
1466
1467impl Drop for GcLock {
1468 fn drop(&mut self) {
1469 let _ = fs::remove_file(&self.path);
1470 }
1471}
1472
1473fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1474 let raw = fs::read_to_string(path).ok()?;
1475 let mut fields = raw.split_whitespace();
1476 let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1477 let host = fields.next().map(str::to_owned);
1478 Some(GcLockHolder { pid, host })
1479}
1480
1481fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1483 let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1484 let mtime = OffsetDateTime::from(mtime);
1485 let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1486 if now - mtime > GC_PID_STALE_AFTER {
1487 return None;
1488 }
1489 let holder = read_gc_pid(path)?;
1490 let pid = holder.pid?;
1491 let same_host = holder.host.as_deref() == Some(host);
1492 if same_host && !crate::lockfile::is_process_running(pid) {
1493 return None;
1494 }
1495 Some(holder)
1496}
1497
1498fn local_host_name() -> String {
1501 #[cfg(unix)]
1502 {
1503 if let Ok(uts) = nix::sys::utsname::uname() {
1504 if let Some(name) = uts.nodename().to_str() {
1505 return name.to_owned();
1506 }
1507 }
1508 }
1509 #[cfg(windows)]
1510 {
1511 if let Some(name) = windows_dns_host_name() {
1512 return name;
1513 }
1514 }
1515 "localhost".to_owned()
1516}
1517
1518#[cfg(windows)]
1520fn windows_dns_host_name() -> Option<String> {
1521 use windows_sys::Win32::System::SystemInformation::{
1522 ComputerNameDnsHostname, GetComputerNameExW,
1523 };
1524 let mut len: u32 = 0;
1525 unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1527 if len == 0 {
1528 return None;
1529 }
1530 let mut buf = vec![0u16; len as usize];
1531 let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1533 (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1534}
1535
1536fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
1537 let mut stats = GcStats::default();
1538 let cfg = repo.config()?;
1539 let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1540 let dry_run = opts.repack.dry_run;
1541
1542 if opts.pack_refs && !dry_run {
1543 let start = Instant::now();
1544 stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1545 stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1546 }
1547
1548 if opts.expire_reflog {
1549 let start = Instant::now();
1550 stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
1551 stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
1552 }
1553
1554 let mut repack_opts = opts.repack.clone();
1555 apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
1556 if repack_opts.now.is_none() {
1557 repack_opts.now =
1558 Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
1559 }
1560 if !repack_opts.write_bitmap && !repack_opts.incremental {
1563 repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg);
1564 }
1565 let repack_start = Instant::now();
1566 stats.repack = repack_all(repo, &repack_opts)?;
1567 let repack_total_ms = duration_ms(repack_start.elapsed());
1568 stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
1569 stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
1570
1571 if dry_run {
1572 return Ok(stats);
1573 }
1574
1575 stats.prune_ran = stats.repack.unreachable_prune_ran;
1578 stats.prune = PruneStats {
1579 pruned: stats.repack.unreachable_loose_pruned,
1580 kept: stats.repack.unreachable_loose_kept,
1581 };
1582
1583 if opts.write_commit_graph {
1584 let start = Instant::now();
1585 stats.commit_graph = Some(write_commit_graph(
1586 repo,
1587 &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
1588 )?);
1589 stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
1590 }
1591 Ok(stats)
1592}
1593
1594enum GcLoosePrunePolicy {
1596 Disabled,
1598 Enabled {
1600 keep_newer_than: Option<SystemTime>,
1602 },
1603}
1604
1605fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
1606 let spec = config
1607 .get("gc.pruneExpire")
1608 .map(|s| s.trim().to_owned())
1609 .filter(|s| !s.is_empty())
1610 .unwrap_or_else(|| "2.weeks.ago".to_owned());
1611 if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
1612 return Ok(GcLoosePrunePolicy::Disabled);
1613 }
1614 if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
1616 return Ok(GcLoosePrunePolicy::Enabled {
1617 keep_newer_than: None,
1618 });
1619 }
1620 let mut err = 0;
1621 let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
1622 if err != 0 {
1623 return Err(Error::Config(ConfigError::Other(format!(
1624 "bad config value '{spec}' for 'gc.pruneExpire'"
1625 ))));
1626 }
1627 let keep_newer_than = SystemTime::UNIX_EPOCH
1628 .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
1629 Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
1630}
1631
1632fn gc_now_unix(now_unix: i64) -> i64 {
1633 if now_unix != 0 {
1634 return now_unix;
1635 }
1636 time::OffsetDateTime::now_utc().unix_timestamp()
1637}
1638
1639fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
1640 let cfg = repo.config()?;
1641 let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
1642 let git_dir = repo.git_dir();
1643 let params = ReflogExpireParams {
1644 stale_fix: false,
1645 dry_run,
1646 verbose: false,
1647 };
1648 let mut total = 0usize;
1649 for refname in list_reflog_refs(git_dir)? {
1650 let result = expire_reflog_git(
1651 repo,
1652 git_dir,
1653 &refname,
1654 ¶ms,
1655 None,
1656 None,
1657 &gc_cfg.patterns,
1658 gc_cfg.global_total,
1659 gc_cfg.global_unreachable,
1660 now,
1661 )?;
1662 total += result.pruned;
1663 }
1664 Ok(total)
1665}
1666
1667fn count_loose_objects(odb: &Odb) -> Result<usize> {
1668 Ok(odb.enumerate_local_loose_objects()?.len())
1669}
1670
1671#[allow(clippy::too_many_arguments)]
1674fn build_repack_pack(
1675 odb: &Odb,
1676 pack_dir: &Path,
1677 roots: &[ObjectId],
1678 shallow: &HashSet<ObjectId>,
1679 keep_unreachable: bool,
1680 opts: &PackBuildOptions,
1681 threads: Option<usize>,
1682 cfg: Option<&ConfigSet>,
1683) -> Result<SerializedDeltaPack> {
1684 let selection = repack_object_list(odb, roots, shallow, keep_unreachable, opts.use_bitmaps)?;
1685 odb.clear_delta_base_cache();
1686 let max_depth = if opts.delta { opts.max_depth } else { 0 };
1687 let settings = RepackPackSettings {
1688 window: opts.window,
1689 max_depth,
1690 reuse_deltas: opts.reuse_deltas,
1691 reuse_objects: opts.reuse_objects,
1692 use_ofs_delta: opts.use_ofs_delta,
1693 window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
1694 search_threads: pack_delta_search_parallel_options(odb, threads)?
1695 .threads
1696 .get(),
1697 write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
1698 };
1699 let git_dir = odb
1700 .config_git_dir()
1701 .map(Path::to_path_buf)
1702 .unwrap_or_else(|| {
1703 pack_dir
1704 .parent()
1705 .and_then(|objects| objects.parent())
1706 .map(Path::to_path_buf)
1707 .unwrap_or_else(|| pack_dir.to_path_buf())
1708 });
1709 crate::git_dir::io_error::io_with_git_path(
1710 &git_dir,
1711 pack_dir,
1712 crate::git_dir::GitDirPathOperation::GcPack,
1713 fs::create_dir_all(pack_dir),
1714 )?;
1715 let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
1716 let written = write_repack_pack(
1717 odb,
1718 &selection.send,
1719 &selection.name_hashes,
1720 &settings,
1721 &staging,
1722 );
1723 match written {
1724 Ok(index_rows) => Ok(SerializedDeltaPack {
1725 bytes: Vec::new(),
1726 index_rows,
1727 staging_path: Some(staging),
1728 }),
1729 Err(err) => {
1730 let _ = fs::remove_file(&staging);
1731 Err(err)
1732 }
1733 }
1734}
1735
1736fn repack_object_list(
1740 odb: &Odb,
1741 roots: &[ObjectId],
1742 shallow: &HashSet<ObjectId>,
1743 keep_unreachable: bool,
1744 use_bitmaps: bool,
1745) -> Result<PackObjectSelection> {
1746 let mut roots = roots.to_vec();
1749 roots.sort_unstable();
1750 let mut selection = enumerate_pack_objects_naming(
1751 odb,
1752 &roots,
1753 &[],
1754 &PackEnumerateOptions {
1755 filter: None,
1756 shallow_grafts: shallow,
1757 have_shallow_grafts: Some(shallow),
1758 use_bitmaps,
1759 exclude_objects: None,
1760 peer_odb: None,
1761 have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
1762 },
1763 NameTrees::Yes,
1764 )?;
1765 if keep_unreachable {
1766 append_repack_unreachable_retention(odb, &mut selection.send)?;
1767 }
1768 Ok(selection)
1769}
1770
1771fn append_repack_unreachable_retention(odb: &Odb, send: &mut Vec<ObjectId>) -> Result<()> {
1773 let mut in_send: OidHashSet = send.iter().copied().collect();
1774 odb.primary()?
1775 .packed_objects()
1776 .for_each_removable_pack_object(&mut |oid| {
1777 if in_send.insert(*oid) {
1778 send.push(*oid);
1779 }
1780 std::ops::ControlFlow::Continue(())
1781 })?;
1782 for (oid, _) in odb.enumerate_local_loose_objects()? {
1783 if in_send.insert(oid) {
1784 send.push(oid);
1785 }
1786 }
1787 Ok(())
1788}
1789
1790fn estimate_repack_object_count(
1791 odb: &Odb,
1792 roots: &[ObjectId],
1793 shallow: &HashSet<ObjectId>,
1794 keep_unreachable: bool,
1795 use_bitmaps: bool,
1796) -> Result<usize> {
1797 if !keep_unreachable {
1798 return estimate_reachable_count(odb, roots, shallow);
1799 }
1800 Ok(repack_object_list(odb, roots, shallow, true, use_bitmaps)?
1801 .send
1802 .len())
1803}
1804
1805fn count_pack_files(pack_dir: &Path) -> usize {
1807 let Ok(entries) = fs::read_dir(pack_dir) else {
1808 return 0;
1809 };
1810 let mut packs = HashSet::new();
1811 let mut kept = HashSet::new();
1812 for entry in entries.filter_map(|e| e.ok()) {
1813 let path = PathBuf::from(entry.file_name());
1814 let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
1815 continue;
1816 };
1817 if ext == "pack" {
1818 packs.insert(stem.to_owned());
1819 } else if ext == "keep" {
1820 kept.insert(stem.to_owned());
1821 }
1822 }
1823 packs.difference(&kept).count()
1824}
1825
1826fn estimate_reachable_count(
1827 odb: &Odb,
1828 roots: &[ObjectId],
1829 shallow: &HashSet<ObjectId>,
1830) -> Result<usize> {
1831 let mut seen: HashSet<ObjectId> = HashSet::new();
1832 let mut queue: VecDeque<ObjectId> = VecDeque::new();
1833 for &root in roots {
1834 if seen.insert(root) {
1835 queue.push_back(root);
1836 }
1837 }
1838 while let Some(oid) = queue.pop_front() {
1839 let obj = match odb.read(&oid) {
1840 Ok(o) => o,
1841 Err(_) => continue,
1842 };
1843 match obj.kind {
1844 ObjectKind::Commit => {
1845 let commit = parse_commit(&obj.data)?;
1846 if !shallow.contains(&oid) {
1847 for parent in commit.parents {
1848 if seen.insert(parent) {
1849 queue.push_back(parent);
1850 }
1851 }
1852 }
1853 if seen.insert(commit.tree) {
1854 queue.push_back(commit.tree);
1855 }
1856 }
1857 ObjectKind::Tree => {
1858 for entry in parse_tree(&obj.data)? {
1859 if entry.mode == 0o160000 {
1860 continue;
1861 }
1862 if seen.insert(entry.oid) {
1863 queue.push_back(entry.oid);
1864 }
1865 }
1866 }
1867 ObjectKind::Tag => {
1868 let tag = parse_tag(&obj.data)?;
1869 if seen.insert(tag.object) {
1870 queue.push_back(tag.object);
1871 }
1872 }
1873 ObjectKind::Blob => {}
1874 }
1875 }
1876 Ok(seen.len())
1877}
1878
1879pub fn remove_superseded_pack_files(pack_dir: &Path, keep_pack: &Path) -> Result<usize> {
1881 let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
1882 let keep_stem = keep_pack
1883 .file_stem()
1884 .and_then(|s| s.to_str())
1885 .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
1886 if !pack_dir.is_dir() {
1887 return Ok(0);
1888 }
1889 let mut removed = 0usize;
1890 for entry in crate::git_dir::io_error::io_with_git_path(
1891 &git_dir,
1892 pack_dir,
1893 crate::git_dir::GitDirPathOperation::GcPack,
1894 fs::read_dir(pack_dir),
1895 )? {
1896 let entry = entry.map_err(|source| {
1897 crate::git_dir::io_error::map_git_dir_io(
1898 &git_dir,
1899 pack_dir,
1900 crate::git_dir::GitDirPathOperation::GcPack,
1901 source,
1902 )
1903 })?;
1904 let name = entry.file_name();
1905 let name_str = name.to_string_lossy();
1906 if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
1907 continue;
1908 }
1909 if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
1910 continue;
1911 }
1912 let path = entry.path();
1913 if path.is_file() {
1914 if pack_has_keep_marker(pack_dir, &path) {
1915 continue;
1916 }
1917 crate::git_dir::io_error::io_with_git_path(
1918 &git_dir,
1919 &path,
1920 crate::git_dir::GitDirPathOperation::GcPack,
1921 fs::remove_file(&path),
1922 )?;
1923 removed += 1;
1924 }
1925 }
1926 Ok(removed)
1927}
1928
1929fn pack_has_keep_marker(pack_dir: &Path, path: &Path) -> bool {
1930 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1931 return false;
1932 };
1933 pack_dir.join(format!("{stem}.keep")).is_file()
1934}
1935
1936pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
1942 let (path, refs_packed, refs_pruned) =
1943 crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
1944 Ok(PackRefsReport {
1945 path,
1946 refs_packed,
1947 refs_pruned,
1948 })
1949}
1950
1951impl Repository {
1952 pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
1958 write_commit_graph(self, opts)
1959 }
1960
1961 pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
1967 write_midx(self, opts)
1968 }
1969}
1970
1971pub mod fsck {
1973 pub use crate::fsck_standalone::*;
1974}
1975
1976pub mod repository_fsck {
1978 pub use crate::fsck::*;
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983 #![allow(clippy::unwrap_used, clippy::expect_used)]
1984
1985 use super::*;
1986 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1987 use crate::porcelain::add::stage_for_commit;
1988 use crate::porcelain::commit::{create_commit, CommitRequest};
1989 use crate::progress::{NeverCancel, NullProgress};
1990 use crate::repo::InitOptions;
1991 use tempfile::TempDir;
1992
1993 #[test]
1994 fn gc_auto_skip_single_pack_without_loose() {
1995 let dir = TempDir::new().unwrap();
1996 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
1997 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
1998 stage_for_commit(&repo, &mut NullProgress).expect("stage");
1999 let ident = "Test <t@example.com> 1 +0000".to_string();
2000 create_commit(
2001 &repo,
2002 &CommitRequest {
2003 message: "init".into(),
2004 author: Some(ident.parse().expect("signature")),
2005 committer: Some(ident.parse().expect("signature")),
2006 allow_empty: false,
2007 sign_override: None,
2008 amend: false,
2009 assume_worktree_synced: false,
2010 skip_pre_commit_tree_match: false,
2011 staged_change_count: None,
2012 ..Default::default()
2013 },
2014 &mut NullProgress,
2015 )
2016 .expect("commit");
2017 git_cmd(dir.path(), &["repack", "-a", "-d"]);
2018 assert!(should_auto_skip_repack(0, 1, false, true, None));
2019 assert!(!should_auto_skip_repack(3, 1, false, true, None));
2020 assert!(!should_auto_skip_repack(0, 2, false, true, None));
2021 assert!(!should_auto_skip_repack(0, 2, false, false, None));
2022 }
2023
2024 #[test]
2025 fn count_pack_files_ignores_keep_markers() {
2026 let dir = TempDir::new().unwrap();
2027 let pack_dir = dir.path().join("pack");
2028 fs::create_dir_all(&pack_dir).unwrap();
2029 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2030 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2031 fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
2032 assert_eq!(count_pack_files(&pack_dir), 1);
2033 }
2034
2035 fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
2036 let dir = TempDir::new().unwrap();
2037 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2038 let config_path = repo.git_dir().join("config");
2039 let mut config = fs::read_to_string(&config_path).unwrap();
2040 config.push_str(snippet);
2041 fs::write(&config_path, config).unwrap();
2042 repo.reload_config().expect("reload config");
2043 (dir, repo)
2044 }
2045
2046 fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
2047 let dir = repo.git_dir().join("objects").join(fanout);
2048 fs::create_dir_all(&dir).unwrap();
2049 for name in names {
2050 fs::write(dir.join(name), b"").unwrap();
2051 }
2052 }
2053
2054 const NAME_A: &str = "00000000000000000000000000000000000001";
2055 const NAME_B: &str = "00000000000000000000000000000000000002";
2056 const NAME_C: &str = "00000000000000000000000000000000000003";
2057
2058 #[test]
2059 fn auto_gc_needed_samples_one_fanout_directory() {
2060 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
2062 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2063 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2064 fake_loose(&repo, "18", &[NAME_C]);
2065 fake_loose(
2066 &repo,
2067 "17",
2068 &["tmp_obj_123", "0000000000000000000000000000000000000z"],
2069 );
2070 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2071 fake_loose(&repo, "17", &[NAME_C]);
2072 assert_eq!(
2073 auto_gc_needed(&repo).unwrap(),
2074 Some(AutoGcReason::TooManyLoose {
2075 estimate: 3 * 256,
2076 limit: 300
2077 })
2078 );
2079 }
2080
2081 #[test]
2082 fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
2083 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
2084 fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
2085 let pack_dir = repo.git_dir().join("objects/pack");
2086 fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2087 fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2088 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2089 assert_eq!(
2090 gc_auto(&repo, &GcOptions::default()).unwrap(),
2091 AutoGcOutcome::NotNeeded
2092 );
2093 }
2094
2095 #[test]
2096 fn auto_gc_needed_counts_packs_without_keep_markers() {
2097 let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
2098 let pack_dir = repo.git_dir().join("objects/pack");
2099 for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
2100 fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
2101 }
2102 assert_eq!(
2103 auto_gc_needed(&repo).unwrap(),
2104 Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
2105 );
2106 fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
2107 assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2108 }
2109
2110 fn config_snippet(text: &str) -> ConfigSet {
2111 let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
2112 let mut set = ConfigSet::new();
2113 set.merge(&file);
2114 set
2115 }
2116
2117 #[test]
2118 fn prune_expire_now_and_all_have_no_grace_window() {
2119 let now = 1_700_000_000;
2120 for spec in ["now", "all", "NOW"] {
2121 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2122 assert!(matches!(
2123 gc_loose_prune_policy(&cfg, now).unwrap(),
2124 GcLoosePrunePolicy::Enabled {
2125 keep_newer_than: None
2126 }
2127 ));
2128 }
2129 for spec in ["never", "false"] {
2130 let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2131 assert!(matches!(
2132 gc_loose_prune_policy(&cfg, now).unwrap(),
2133 GcLoosePrunePolicy::Disabled
2134 ));
2135 }
2136 let cfg = ConfigSet::new();
2137 assert!(matches!(
2138 gc_loose_prune_policy(&cfg, now).unwrap(),
2139 GcLoosePrunePolicy::Enabled {
2140 keep_newer_than: Some(_)
2141 }
2142 ));
2143 }
2144
2145 #[test]
2146 fn gc_lock_is_exclusive_and_released_on_drop() {
2147 let (_dir, repo) = repo_with_config("");
2148 let now = OffsetDateTime::now_utc().unix_timestamp();
2149 let pid_path = repo.git_dir().join("gc.pid");
2150 let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
2151 panic!("first acquire must succeed");
2152 };
2153 let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
2154 assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
2155 assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
2156 match GcLock::try_acquire(&repo, now).unwrap() {
2157 GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
2158 GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
2159 }
2160 assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
2161 assert!(matches!(
2162 gc(&repo, &GcOptions::default()),
2163 Err(Error::MaintenanceRunning { .. })
2164 ));
2165 assert!(!repo.git_dir().join("gc.pid.lock").exists());
2166 drop(lock);
2167 assert!(!pid_path.exists());
2168 assert_eq!(GcLock::current_holder(&repo, now), None);
2169 assert!(matches!(
2170 GcLock::try_acquire(&repo, now).unwrap(),
2171 GcLockAttempt::Acquired(_)
2172 ));
2173 }
2174
2175 #[test]
2176 fn gc_lock_takes_over_stale_entries() {
2177 let (_dir, repo) = repo_with_config("");
2178 let now = OffsetDateTime::now_utc().unix_timestamp();
2179 let pid_path = repo.git_dir().join("gc.pid");
2180 let host = local_host_name();
2181
2182 fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
2184 assert!(matches!(
2185 GcLock::try_acquire(&repo, now).unwrap(),
2186 GcLockAttempt::Acquired(_)
2187 ));
2188
2189 fs::write(&pid_path, "4242 some-other-host").unwrap();
2191 assert!(matches!(
2192 GcLock::try_acquire(&repo, now).unwrap(),
2193 GcLockAttempt::Held(GcLockHolder {
2194 pid: Some(4242),
2195 ..
2196 })
2197 ));
2198
2199 let later = now + time::Duration::hours(13).whole_seconds();
2201 assert!(matches!(
2202 GcLock::try_acquire(&repo, later).unwrap(),
2203 GcLockAttempt::Acquired(_)
2204 ));
2205 }
2206
2207 #[test]
2208 fn gc_auto_reports_held_lock_without_running() {
2209 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
2210 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2211 fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
2212 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
2213 assert!(
2214 matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
2215 "{outcome:?}"
2216 );
2217 assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
2218 }
2219
2220 #[cfg(unix)]
2221 #[test]
2222 fn gc_auto_honors_failing_pre_auto_gc_hook() {
2223 use std::os::unix::fs::PermissionsExt as _;
2224 let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
2225 fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2226 let hook = repo.git_dir().join("hooks/pre-auto-gc");
2227 fs::create_dir_all(hook.parent().unwrap()).unwrap();
2228 fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
2229 fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
2230 let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
2231 assert!(
2232 matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
2233 "{outcome:?}"
2234 );
2235 assert!(!repo.git_dir().join("gc.pid").exists());
2236 }
2237
2238 fn git_cmd(dir: &Path, args: &[&str]) {
2239 let status = std::process::Command::new("git")
2240 .current_dir(dir)
2241 .args(args)
2242 .env("GIT_CONFIG_GLOBAL", "/dev/null")
2243 .env("GIT_CONFIG_SYSTEM", "/dev/null")
2244 .status()
2245 .expect("git");
2246 assert!(status.success(), "git {args:?}");
2247 }
2248
2249 fn assert_fsck_clean(repo: &Repository) {
2250 let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
2251 let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
2252 assert!(
2253 report.ok(&repo.config().expect("config"), &opts),
2254 "{:?}",
2255 report.issues
2256 );
2257 }
2258
2259 #[test]
2260 fn fsck_clean_repo_ok() {
2261 let dir = TempDir::new().unwrap();
2262 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2263 std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2264 stage_for_commit(&repo, &mut NullProgress).expect("stage");
2265 let ident = "Test <t@example.com> 1 +0000".to_string();
2266 create_commit(
2267 &repo,
2268 &CommitRequest {
2269 message: "init".into(),
2270 author: Some(ident.parse().expect("signature")),
2271 committer: Some(ident.parse().expect("signature")),
2272 allow_empty: false,
2273 sign_override: None,
2274 amend: false,
2275 assume_worktree_synced: false,
2276 skip_pre_commit_tree_match: false,
2277 staged_change_count: None,
2278 ..Default::default()
2279 },
2280 &mut NullProgress,
2281 )
2282 .expect("commit");
2283 assert_fsck_clean(&repo);
2284 }
2285
2286 #[test]
2287 fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
2288 let dir = TempDir::new().unwrap();
2289 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2290 let wt = repo.work_tree().unwrap();
2291 std::fs::write(wt.join("base"), b"v1").unwrap();
2292 stage_for_commit(&repo, &mut NullProgress).expect("stage");
2293 let ident = "Test <t@example.com> 1 +0000".to_string();
2294 create_commit(
2295 &repo,
2296 &CommitRequest {
2297 message: "base".into(),
2298 author: Some(ident.parse().expect("signature")),
2299 committer: Some(ident.parse().expect("signature")),
2300 allow_empty: false,
2301 sign_override: None,
2302 amend: false,
2303 assume_worktree_synced: false,
2304 skip_pre_commit_tree_match: false,
2305 staged_change_count: None,
2306 ..Default::default()
2307 },
2308 &mut NullProgress,
2309 )
2310 .expect("commit");
2311 repack_all(
2312 &repo,
2313 &RepackOptions {
2314 keep_unreachable: false,
2315 ..RepackOptions::default()
2316 },
2317 )
2318 .expect("repack base");
2319 let pack_dir = repo.git_dir().join("objects/pack");
2320 assert_eq!(count_pack_files(&pack_dir), 1);
2321
2322 std::fs::write(wt.join("child"), b"v2").unwrap();
2323 stage_for_commit(&repo, &mut NullProgress).expect("stage child");
2324 create_commit(
2325 &repo,
2326 &CommitRequest {
2327 message: "child".into(),
2328 author: Some(ident.parse().expect("signature")),
2329 committer: Some(ident.parse().expect("signature")),
2330 allow_empty: false,
2331 sign_override: None,
2332 amend: false,
2333 assume_worktree_synced: false,
2334 skip_pre_commit_tree_match: false,
2335 staged_change_count: None,
2336 ..Default::default()
2337 },
2338 &mut NullProgress,
2339 )
2340 .expect("child commit");
2341 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
2342
2343 let packs_before = count_pack_files(&pack_dir);
2344 repack_all(
2345 &repo,
2346 &RepackOptions {
2347 incremental: true,
2348 write_bitmap: true,
2349 ..RepackOptions::default()
2350 },
2351 )
2352 .expect("incremental repack with bitmap must not fail after installing a pack");
2353
2354 assert!(
2355 count_pack_files(&pack_dir) > packs_before,
2356 "incremental repack should add a pack"
2357 );
2358 let midx = pack_dir.join("multi-pack-index");
2359 assert!(
2360 midx.is_file(),
2361 "expected multi-pack-index after bitmap repack"
2362 );
2363 let midx_bytes = fs::read(&midx).expect("read midx");
2364 assert_eq!(
2365 midx_bytes.get(4),
2366 Some(&1),
2367 "MIDX on-disk version must be v1 for system git compatibility"
2368 );
2369 let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
2370 .unwrap()
2371 .filter_map(|e| e.ok())
2372 .map(|e| e.path())
2373 .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
2374 .collect();
2375 assert!(
2376 !bitmaps.is_empty(),
2377 "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
2378 );
2379
2380 assert_fsck_clean(&repo);
2381 }
2382
2383 #[test]
2384 fn incremental_repack_adds_pack_without_removing_base() {
2385 let dir = TempDir::new().unwrap();
2386 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2387 let wt = repo.work_tree().unwrap();
2388 std::fs::write(wt.join("base"), b"v1").unwrap();
2389 stage_for_commit(&repo, &mut NullProgress).expect("stage");
2390 let ident = "Test <t@example.com> 1 +0000".to_string();
2391 create_commit(
2392 &repo,
2393 &CommitRequest {
2394 message: "base".into(),
2395 author: Some(ident.parse().expect("signature")),
2396 committer: Some(ident.parse().expect("signature")),
2397 allow_empty: false,
2398 sign_override: None,
2399 amend: false,
2400 assume_worktree_synced: false,
2401 skip_pre_commit_tree_match: false,
2402 staged_change_count: None,
2403 ..Default::default()
2404 },
2405 &mut NullProgress,
2406 )
2407 .expect("commit");
2408 repack_all(
2409 &repo,
2410 &RepackOptions {
2411 keep_unreachable: false,
2412 ..RepackOptions::default()
2413 },
2414 )
2415 .expect("repack");
2416 let pack_dir = repo.git_dir().join("objects/pack");
2417 let packs_before = count_pack_files(&pack_dir);
2418 assert_eq!(packs_before, 1);
2419 for i in 0..5 {
2420 create_commit(
2421 &repo,
2422 &CommitRequest {
2423 message: format!("empty-{i}"),
2424 author: Some(ident.parse().expect("signature")),
2425 committer: Some(ident.parse().expect("signature")),
2426 allow_empty: true,
2427 sign_override: None,
2428 amend: false,
2429 assume_worktree_synced: false,
2430 skip_pre_commit_tree_match: false,
2431 staged_change_count: None,
2432 ..Default::default()
2433 },
2434 &mut NullProgress,
2435 )
2436 .expect("empty");
2437 }
2438 assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
2439 repack_all(
2440 &repo,
2441 &RepackOptions {
2442 incremental: true,
2443 write_bitmap: false,
2444 ..RepackOptions::default()
2445 },
2446 )
2447 .expect("incremental repack");
2448 assert!(
2449 count_pack_files(&pack_dir) >= 2,
2450 "incremental repack should add a pack, not replace the base"
2451 );
2452 }
2453
2454 #[test]
2455 fn gc_full_run_records_per_phase_timings() {
2456 let dir = TempDir::new().unwrap();
2457 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2458 let wt = repo.work_tree().unwrap();
2459 for i in 0..5 {
2460 std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
2461 stage_for_commit(&repo, &mut NullProgress).expect("stage");
2462 let ident = "Test <t@example.com> 1 +0000".to_string();
2463 create_commit(
2464 &repo,
2465 &CommitRequest {
2466 message: format!("c{i}"),
2467 author: Some(ident.parse().expect("signature")),
2468 committer: Some(ident.parse().expect("signature")),
2469 allow_empty: false,
2470 sign_override: None,
2471 amend: false,
2472 assume_worktree_synced: false,
2473 skip_pre_commit_tree_match: false,
2474 staged_change_count: None,
2475 ..Default::default()
2476 },
2477 &mut NullProgress,
2478 )
2479 .expect("commit");
2480 }
2481 let stats = gc(
2482 &repo,
2483 &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
2484 )
2485 .expect("gc");
2486 let t = stats.phase_timings;
2487 assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
2488 assert!(
2489 t.repack_ms > 0,
2490 "repack phase must be timed separately from full gc"
2491 );
2492 assert!(stats.pack_refs.is_some());
2493 assert!(stats.commit_graph.is_some());
2494 assert_eq!(
2495 t.prune_ms, stats.repack.unreachable_prune_ms,
2496 "prune_ms must track unreachable loose prune inside repack, not repack_ms"
2497 );
2498 assert!(
2501 t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
2502 "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
2503 );
2504 assert!(stats.prune_ran, "full gc should run unreachable prune");
2505 }
2506}