Skip to main content

grit_lib/
maintenance.rs

1//! Repository maintenance: repack, fsck, and garbage collection orchestration.
2//!
3//! These APIs wrap existing pack, prune, and fsck primitives for embedders and the
4//! `grit` CLI. They never shell out to the system `git` binary.
5//!
6//! Library guide: [maintenance](https://grit-scm.com/docs/library/maintenance)
7//!
8//! ```rust
9//! use grit_lib::repo::{InitOptions, Repository};
10//! # fn main() -> grit_lib::error::Result<()> {
11//! let dir = tempfile::tempdir()?;
12//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
13//! # Ok(())
14//! # }
15//! ```
16
17use 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/// Options for [`repack_all()`].
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct RepackOptions {
69    /// Report actions without writing packs or deleting files.
70    pub dry_run: bool,
71    /// After repack, write `objects/info/commit-graph` when the ODB is files-backed.
72    pub write_commit_graph: bool,
73    /// After repack, write a `.bitmap` sidecar for the new pack (files-backed only).
74    pub write_bitmap: bool,
75    /// When set, prune unreachable loose objects not newer than this instant after repack.
76    pub prune_older_than: Option<OffsetDateTime>,
77    /// Wall clock passed to maintenance helpers that require an explicit time (e.g. bitmap writes).
78    pub now: Option<OffsetDateTime>,
79    /// When true (the default), do not delete unreachable loose objects after repack (matches `git repack -ad`).
80    pub keep_unreachable: bool,
81    /// Worker threads for pack indexing (`None` reads `pack.threads` / CPU count).
82    pub threads: Option<usize>,
83    /// Override the delta settings of a full repack; `None` uses
84    /// [`PackBuildOptions::for_local_copy`] (`pack.window` / `pack.depth` from config).
85    ///
86    /// A full repack honors `delta`, `window`, `max_depth`, `reuse_deltas`, `reuse_objects`,
87    /// `use_ofs_delta` and `use_bitmaps`. Objects already in a pack keep their stored form
88    /// when the reuse flags allow it; only the rest go through the `window` search.
89    pub pack_build: Option<PackBuildOptions>,
90    /// Config/environment for ingest and pack threads (defaults to repo config).
91    pub merged_config: Option<ConfigSet>,
92    /// Process environment for config discovery and thread counts.
93    pub environment: Environment,
94    /// Pack only the reachable loose objects into a new pack, keeping existing packs (Git
95    /// `repack -d` without `-a`). Unreachable loose objects stay loose for the prune step.
96    ///
97    /// When [`Self::write_bitmap`] is true and the repository already has pack files, bitmaps are
98    /// written via a multi-pack-index sidecar instead of a per-pack `.bitmap` on the new pack alone.
99    pub incremental: bool,
100    /// When true, [`gc()`] may skip repack on a single pack with no loose objects when every
101    /// packed object is still reachable and none would be dropped by the active prune policy.
102    /// Explicit [`repack_all`] / [`repack`] leave this false so maintenance still runs.
103    pub auto_skip: bool,
104    /// When true, recompute every delta instead of keeping the ones stored in packs
105    /// (`git repack -f`, `grit repack --force`). Whole objects that stay whole are
106    /// still copied verbatim.
107    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    /// Start from [`Default::default`] with [`Self::dry_run`] set.
132    #[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/// Counters produced by [`repack`].
150#[derive(Debug, Clone, Default, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct RepackReport {
153    /// New pack files installed (0 or 1 for all-into-one repack).
154    pub packs_written: usize,
155    /// Pack sidecar files removed from `objects/pack/` (excluding `.keep` packs).
156    pub packs_removed: usize,
157    /// Loose object files removed (`prune-packed` plus unreachable prune).
158    pub loose_removed: usize,
159    /// Objects written into the new pack.
160    pub objects: usize,
161}
162
163/// Extended counters and paths produced by [`repack_all`].
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165#[non_exhaustive]
166pub struct RepackStats {
167    /// Loose object files before repack.
168    pub loose_before: usize,
169    /// Pack files before repack.
170    pub packs_before: usize,
171    /// Objects written into the new pack (`None` when [`RepackOptions::dry_run`]).
172    pub objects_packed: Option<usize>,
173    /// Bytes in the new pack (`None` on dry run).
174    pub pack_bytes: Option<u64>,
175    /// Path to the installed pack (`None` on dry run or when nothing was packed).
176    pub pack_path: Option<PathBuf>,
177    /// Sidecar/index files removed from `objects/pack/`.
178    pub removed_pack_files: usize,
179    /// Loose objects removed because they were redundant with the new pack.
180    pub pruned_loose: usize,
181    /// Unreachable loose objects removed after repack when pruning is enabled.
182    pub unreachable_loose_pruned: usize,
183    /// Unreachable loose objects kept (grace window or concurrent safety).
184    pub unreachable_loose_kept: usize,
185    /// Wall time spent in unreachable loose-object pruning during this repack.
186    pub unreachable_prune_ms: u64,
187    /// True when unreachable loose-object pruning was executed (even if nothing was removed).
188    pub unreachable_prune_ran: bool,
189}
190
191/// Options for [`Repository::write_commit_graph`].
192#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
193#[non_exhaustive]
194pub struct CommitGraphOptions {
195    /// When true, emit Bloom filter chunks for changed-path queries.
196    pub changed_paths: bool,
197    /// Report without writing `objects/info/commit-graph`.
198    pub dry_run: bool,
199}
200
201impl CommitGraphOptions {
202    /// Build options with an explicit changed-paths Bloom setting.
203    #[must_use]
204    pub fn with_changed_paths(changed_paths: bool) -> Self {
205        Self {
206            changed_paths,
207            ..Self::default()
208        }
209    }
210
211    /// Build options with [`Self::dry_run`] set.
212    #[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/// Result of writing `objects/info/commit-graph`.
222#[derive(Debug, Clone, PartialEq, Eq)]
223#[non_exhaustive]
224pub struct CommitGraphWriteReport {
225    /// Path to the monolithic commit-graph file.
226    pub path: PathBuf,
227    /// Commits encoded in the graph (`0` when [`Self::skipped`] is set).
228    pub commit_count: usize,
229    /// Whether a split `commit-graphs/` chain was removed.
230    pub removed_chain: bool,
231    /// Bloom generation counters when [`CommitGraphOptions::changed_paths`] was set and a
232    /// graph was written.
233    pub bloom_stats: Option<BloomWriteStats>,
234    /// Why no graph was written, when the repository rewrites parent lists.
235    pub skipped: Option<CommitGraphSkipReason>,
236}
237
238/// Why [`write_commit_graph`] wrote no graph.
239///
240/// A commit-graph stores each commit's parents as recorded in the commit object. When the
241/// repository overrides those parents, a graph would disagree with what Git sees: Git
242/// neither writes nor reads a graph in that state, and `git fsck` rejects one that exists.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum CommitGraphSkipReason {
246    /// `.git/shallow` lists boundary commits whose parents are cut off.
247    Shallow,
248    /// `info/grafts` overrides the parents of at least one commit.
249    Grafts,
250    /// Replace refs (`refs/replace/`) are present and enabled.
251    ReplaceRefs,
252}
253
254impl CommitGraphSkipReason {
255    /// Stable kebab-case identifier: `shallow`, `grafts` or `replace-refs`.
256    #[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    /// Check whether the repository at `git_dir` rewrites parent lists.
266    ///
267    /// `git_dir` may be a linked worktree's git directory; shallow and graft files are read
268    /// from the common directory. `has_replace_refs` is only called when neither applies and
269    /// reports whether enabled replace refs exist.
270    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    /// Like [`Self::detect`] for an open repository, honoring `core.useReplaceRefs`,
285    /// `GIT_NO_REPLACE_OBJECTS` and `GIT_REPLACE_REF_BASE`.
286    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/// Options for [`Repository::write_midx`].
297#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
298#[non_exhaustive]
299pub struct MidxOptions {
300    /// Write a reachability `.bitmap` sidecar when the MIDX is a root file.
301    pub bitmap: bool,
302    /// Report without writing `multi-pack-index`.
303    pub dry_run: bool,
304    /// On-disk MIDX version (`1` or `2`). `None` uses `midx.version` from the repository
305    /// config, and v1 when that is unset, so every Git with multi-pack-index support can
306    /// read the result.
307    pub version: Option<u8>,
308}
309
310impl MidxOptions {
311    /// Build MIDX write options with an explicit bitmap sidecar flag.
312    #[must_use]
313    pub fn with_bitmap(bitmap: bool) -> Self {
314        Self {
315            bitmap,
316            ..Self::default()
317        }
318    }
319
320    /// Build options with [`Self::dry_run`] set.
321    #[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/// Result of writing `objects/pack/multi-pack-index`.
331#[derive(Debug, Clone, PartialEq, Eq)]
332#[non_exhaustive]
333pub struct MidxWriteReport {
334    /// Path to the tip MIDX file.
335    pub path: PathBuf,
336    /// Pack indexes covered by the MIDX.
337    pub pack_count: usize,
338    /// Whether a reachability bitmap was written or skipped.
339    pub bitmap: MidxBitmapWriteOutcome,
340}
341
342/// Options for [`References::pack`](crate::refs::References::pack).
343#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
344#[non_exhaustive]
345pub struct PackRefsOptions {
346    /// When true, pack every packable ref (`git pack-refs --all`); otherwise only tags.
347    pub all: bool,
348    /// Report without rewriting `packed-refs` or deleting loose refs.
349    pub dry_run: bool,
350}
351
352impl PackRefsOptions {
353    /// Build pack-refs options matching `git pack-refs` / `--all`.
354    #[must_use]
355    pub fn with_all(all: bool) -> Self {
356        Self {
357            all,
358            ..Self::default()
359        }
360    }
361
362    /// Build options with [`Self::dry_run`] set.
363    #[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/// Result of packing refs (files `packed-refs` or reftable compaction).
373#[derive(Debug, Clone, PartialEq, Eq)]
374#[non_exhaustive]
375pub struct PackRefsReport {
376    /// `packed-refs` path or the `reftable/` directory.
377    pub path: PathBuf,
378    /// Loose refs merged into the packed store.
379    pub refs_packed: usize,
380    /// Loose ref files removed (files backend) or tables merged (reftable).
381    pub refs_pruned: usize,
382}
383
384/// Options for [`gc()`].
385#[derive(Debug, Clone)]
386#[non_exhaustive]
387pub struct GcOptions {
388    /// Repack options applied before pruning unreachable objects.
389    pub repack: RepackOptions,
390    /// Expire reflogs using `gc.reflogExpire*` before repack/prune.
391    pub expire_reflog: bool,
392    /// Unix seconds for reflog expiry rules.
393    pub now_unix: i64,
394    /// When true, pack loose refs before other gc steps.
395    pub pack_refs: bool,
396    /// When true, write `objects/info/commit-graph` after repack/prune.
397    ///
398    /// Changed-path Bloom filters are kept when the existing graph has them and are not
399    /// added otherwise, matching `git gc`.
400    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    /// Run gc with repack dry-run control and an explicit wall-clock for reflog expiry.
420    #[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    /// Default repack with explicit reflog expiry policy and wall-clock.
435    #[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    /// Full maintenance run: pack-refs, reflog expiry, repack, prune, commit-graph.
449    #[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    /// Prune with an optional explicit cutoff (`None` uses `gc.pruneExpire`).
468    #[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
477/// Object ids that must stay reachable during repack, prune, and fsck walks.
478///
479/// Includes ref tips, symbolic `HEAD`, every reflog `old`/`new` oid, and every
480/// blob/tree oid recorded in the index (including unmerged stages; gitlinks are excluded).
481///
482/// # Errors
483///
484/// Propagates ref, reflog, or index load failures.
485pub 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
510/// Whether the current commit-graph carries changed-path Bloom filters.
511///
512/// Maintenance rewrites keep Bloom filters when the graph already has them and otherwise
513/// write without them, as Git's gc does: computing filters from scratch diffs every
514/// commit's tree and costs far more than the rest of the graph.
515fn 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
530/// Write a monolithic reachable commit-graph under `objects/info/commit-graph`.
531///
532/// Reachable commits are discovered from [`Repository::refs`](crate::repo::Repository::refs)
533/// (reftable, packed-refs, and linked-worktree visibility), not by scanning `git_dir/refs` alone.
534///
535/// In a shallow repository, with `info/grafts`, or with enabled replace refs, nothing is
536/// written and [`CommitGraphWriteReport::skipped`] says why (see [`CommitGraphSkipReason`]).
537/// Git only skips the write in that state, but `git fsck` still verifies a graph that is
538/// already there, so an existing graph and split chain are deleted as well (except in
539/// [`CommitGraphOptions::dry_run`]).
540///
541/// # Errors
542///
543/// Returns [`Error::UnsupportedObjectStore`] for non-files ODB backends, or errors from graph
544/// generation and atomic I/O.
545pub 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
642/// Write or refresh `objects/pack/multi-pack-index` for the repository.
643///
644/// The on-disk version comes from [`MidxOptions::version`], then `midx.version`, then v1.
645///
646/// # Errors
647///
648/// Returns [`Error::UnsupportedObjectStore`] for non-files backends, [`Error::Config`] when
649/// `midx.version` is not an integer, [`crate::error::MidxError::UnknownWriteVersion`] (as
650/// [`Error::Midx`]) when the version is not `1` or `2`, or MIDX write failures.
651pub 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/// Options for [`prune_loose_objects`].
697#[derive(Debug, Clone, Default)]
698#[non_exhaustive]
699pub struct PruneOptions {
700    /// When set, only delete unreachable loose objects strictly older than this time.
701    ///
702    /// When `None`, uses `gc.pruneExpire` from repository config (default `2.weeks.ago`).
703    pub older_than: Option<SystemTime>,
704    /// Report without deleting objects.
705    pub dry_run: bool,
706}
707
708/// Wall-clock milliseconds for each phase of [`gc`].
709#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
710#[non_exhaustive]
711pub struct GcPhaseTimings {
712    /// [`pack_refs`] when run as part of gc.
713    pub pack_refs_ms: u64,
714    /// Reflog expiry before repack.
715    pub reflog_ms: u64,
716    /// Repack work excluding embedded unreachable prune.
717    pub repack_ms: u64,
718    /// Unreachable loose-object prune (during repack or standalone).
719    pub prune_ms: u64,
720    /// Commit-graph write after repack.
721    pub commit_graph_ms: u64,
722}
723
724/// Result of [`gc`].
725#[derive(Debug, Clone, Default, PartialEq, Eq)]
726pub struct GcStats {
727    /// Loose refs packed during gc (`None` when skipped).
728    pub pack_refs: Option<PackRefsReport>,
729    /// Counters from the repack phase.
730    pub repack: RepackStats,
731    /// Counters from loose-object pruning after repack.
732    pub prune: PruneStats,
733    /// True when full gc ran unreachable loose-object pruning during repack.
734    pub prune_ran: bool,
735    /// Monolithic commit-graph written after repack/prune (`None` when skipped).
736    pub commit_graph: Option<CommitGraphWriteReport>,
737    /// Reflog lines removed by expiry during gc.
738    pub reflog_entries_pruned: usize,
739    /// Per-phase wall times for the gc orchestration.
740    pub phase_timings: GcPhaseTimings,
741}
742
743/// Repack all reachable objects into a single pack and drop redundant storage.
744///
745/// Mirrors a minimal `git repack -ad`: one pack for [`reachability_roots`](crate::fsck::reachability_roots), optional `.rev` /
746/// `.bitmap` sidecars, delete other packs, [`prune_packed_objects`], and optional unreachable
747/// loose-object pruning.
748///
749/// # Errors
750///
751/// Propagates pack build/install failures, unsupported custom ODB backends, and I/O errors.
752pub 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    // An incremental run with only unreachable loose objects writes no pack but must still
862    // expire them below.
863    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            // Locally built pack: trailer checked at write time; index rows match the stream.
885            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                // Per-pack bitmaps require reachability closure within one pack; incremental
911                // repack keeps older packs and adds a new layer. Write a multi-pack-index
912                // bitmap covering all packs instead of a sidecar on the new pack alone.
913                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        // Incremental repack keeps every existing pack, so nothing is superseded.
943        if !opts.incremental {
944            odb.loosen_unreachable_from_superseded_packs(
945                &pack_dir, &pack_path, &reachable, retention,
946            )?;
947        }
948
949        // Drop cached pack/index mmaps before deleting superseded pack artifacts (Windows).
950        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
991/// Like [`repack`], with legacy [`RepackStats`] fields for the CLI.
992pub 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
1064/// Loose objects reachable from the maintenance roots: what Git's incremental `repack -d`
1065/// packs. Unreachable loose objects stay loose so `gc.pruneExpire` can expire them.
1066fn 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
1104/// Whether maintenance should skip rebuilding packs when [`RepackOptions::auto_skip`] is set.
1105///
1106/// Used by [`gc`]: skip when a single pack already holds all objects and nothing is loose.
1107/// Multiple packs never skip (consolidation required), even when `gc.autoPackLimit` is zero.
1108fn 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
1127/// True when skipping repack would leave unreachable objects in pack storage that repack would
1128/// remove under `opts` (for example expired unreachable commits when `gc.pruneExpire=now`).
1129///
1130/// The reachability walk only runs once some pack is old enough to have objects dropped.
1131fn 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
1174/// Delete unreachable loose objects using `gc.pruneExpire` or an explicit cutoff.
1175///
1176/// # Errors
1177///
1178/// Propagates config parse failures, unsupported ODB backends, and I/O errors.
1179pub 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
1205/// Run pack-refs, reflog expiry, repack, prune, and commit-graph maintenance.
1206///
1207/// Unless [`RepackOptions::dry_run`] is set, holds the repository's `gc.pid` lock for the
1208/// whole run, so it never overlaps another grit or Git gc (see [`GcLock`]).
1209///
1210/// # Errors
1211///
1212/// [`Error::MaintenanceRunning`] when another process holds the `gc.pid` lock, plus failures
1213/// from each maintenance step.
1214pub 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/// Why automatic maintenance is due, as decided by [`auto_gc_needed`].
1229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1230pub enum AutoGcReason {
1231    /// The loose-object estimate is above `gc.auto`. [`gc_auto`] packs the reachable loose
1232    /// objects into one new pack and leaves existing packs alone.
1233    TooManyLoose {
1234        /// Loose objects in the sampled fan-out directory (`objects/17`) times 256.
1235        estimate: usize,
1236        /// Effective `gc.auto` threshold.
1237        limit: usize,
1238    },
1239    /// There are more non-`.keep` packs than `gc.autoPackLimit`. [`gc_auto`] consolidates
1240    /// every pack into one.
1241    TooManyPacks {
1242        /// Local packs without a `.keep` marker.
1243        count: usize,
1244        /// Effective `gc.autoPackLimit`.
1245        limit: usize,
1246    },
1247}
1248
1249/// Fan-out directory sampled to estimate the loose-object count, as Git documents for `gc.auto`.
1250const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1251
1252/// Decide whether automatic maintenance is due, using Git's `gc --auto` heuristics.
1253///
1254/// Pack consolidation wins when the number of local packs without a `.keep` marker is above
1255/// [`ConfigSet::gc_auto_pack_limit`]. Otherwise the loose-object count is estimated from a
1256/// single fan-out directory (`objects/17`) and compared with [`ConfigSet::gc_auto_threshold`],
1257/// so the check costs two directory reads however large the repository is. `gc.auto <= 0`
1258/// turns both checks off. Repositories without a files object store never need it.
1259///
1260/// This does not look at `maintenance.auto`; callers that start maintenance after a command
1261/// check [`ConfigSet::maintenance_auto`] themselves.
1262///
1263/// # Errors
1264///
1265/// Config load failures and I/O errors other than a missing directory.
1266pub 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
1291/// Count entries of one loose-object fan-out directory that look like object files.
1292fn 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/// Result of [`gc_auto`].
1309#[derive(Debug, Clone, PartialEq, Eq)]
1310#[non_exhaustive]
1311pub enum AutoGcOutcome {
1312    /// Neither threshold is exceeded (or `gc.auto` is off); nothing ran.
1313    NotNeeded,
1314    /// Another process holds the `gc.pid` lock; nothing ran.
1315    AlreadyRunning(GcLockHolder),
1316    /// The `pre-auto-gc` hook exited non-zero; nothing ran.
1317    DeclinedByHook {
1318        /// Exit status of the hook.
1319        status: i32,
1320        /// What the hook wrote, when hook output is captured ([`Repository::hook_stdio`]).
1321        output: crate::hooks::HookOutput,
1322    },
1323    /// Maintenance ran.
1324    Ran {
1325        /// Which threshold triggered the run.
1326        reason: AutoGcReason,
1327        /// What each step did.
1328        stats: Box<GcStats>,
1329    },
1330}
1331
1332/// Run maintenance only when Git's `gc --auto` heuristics say it is due ([`auto_gc_needed`]).
1333///
1334/// When too many loose objects triggered the run, the reachable ones are packed into one new
1335/// pack without rewriting existing packs and without a bitmap, so the cost scales with the
1336/// loose objects rather than the repository. Unreachable loose objects stay loose until
1337/// `gc.pruneExpire` lets the prune step delete them, as in Git. Too many packs runs a full consolidating [`gc`]. Either way the
1338/// other `opts` steps (pack-refs, reflog expiry, unreachable prune under `gc.pruneExpire`,
1339/// commit-graph) run as in [`gc`].
1340///
1341/// Before doing any work this takes the `gc.pid` lock ([`GcLock`]) and runs the `pre-auto-gc`
1342/// hook; a held lock or a failing hook ends the call without changes. With
1343/// [`RepackOptions::dry_run`] neither happens and the returned stats describe the planned work.
1344///
1345/// The library never forks: callers that honor [`ConfigSet::gc_auto_detach`] start a separate
1346/// process that calls this function.
1347///
1348/// # Errors
1349///
1350/// Config, hook spawn, lock and I/O failures, and any error from the maintenance steps.
1351pub 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/// Process recorded in `gc.pid` by a maintenance run that is still in progress.
1385#[derive(Debug, Clone, PartialEq, Eq)]
1386pub struct GcLockHolder {
1387    /// Process id from `gc.pid`, when it could be read.
1388    pub pid: Option<u64>,
1389    /// Host name from `gc.pid`, when it could be read.
1390    pub host: Option<String>,
1391}
1392
1393/// Outcome of [`GcLock::try_acquire`].
1394#[derive(Debug)]
1395#[must_use]
1396pub enum GcLockAttempt {
1397    /// This process now owns `gc.pid`.
1398    Acquired(GcLock),
1399    /// Another live maintenance run owns it.
1400    Held(GcLockHolder),
1401}
1402
1403/// How long a `gc.pid` written by another run is honored before it is treated as stale.
1404const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1405
1406/// Repository-wide maintenance lock, compatible with Git's `gc.pid`.
1407///
1408/// The lock file holds `"<pid> <hostname>"` and is removed when this value is dropped. An
1409/// existing `gc.pid` counts as held while it is younger than 12 hours and either names another
1410/// host or a process on this host that is still running; older or dead entries are taken over.
1411#[derive(Debug)]
1412pub struct GcLock {
1413    path: PathBuf,
1414}
1415
1416impl GcLock {
1417    /// Try to take the `gc.pid` lock in the repository's common directory without waiting.
1418    ///
1419    /// `now_unix` is the wall clock used to decide whether an existing `gc.pid` is stale.
1420    ///
1421    /// # Errors
1422    ///
1423    /// I/O failures while creating, writing or renaming the lock file.
1424    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    /// The maintenance run currently holding `gc.pid`, without taking the lock.
1456    ///
1457    /// Uses the same staleness rules as [`Self::try_acquire`], so `None` means an attempt would
1458    /// most likely succeed. Lets a caller avoid starting a process that would only find the
1459    /// lock held.
1460    #[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
1481/// The holder recorded in `gc.pid` when it still blocks a new run on `host`.
1482fn 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
1498/// Host name as Git records it in `gc.pid` (`gethostname`), so either tool can tell whether
1499/// the other's lock belongs to this machine.
1500fn 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/// The DNS host name, which is what Winsock `gethostname` (used by Git for Windows) returns.
1519#[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    // SAFETY: a null buffer with zero length only asks for the required size in `len`.
1526    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    // SAFETY: `buf` has room for the `len` UTF-16 units the previous call asked for.
1532    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    // Incremental runs skip bitmaps, as Git's `gc --auto` does: a bitmap covers the whole
1561    // object graph and costs far more than packing the loose objects.
1562    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    // Repack prunes unreachable loose objects itself (sharing its reachability walk), so gc
1576    // only reports the outcome.
1577    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
1594/// How [`gc`] applies loose-object pruning for `gc.pruneExpire`.
1595enum GcLoosePrunePolicy {
1596    /// `gc.pruneExpire=never` — do not delete unreachable loose objects.
1597    Disabled,
1598    /// Prune unreachable loose objects not protected by the grace cutoff.
1599    Enabled {
1600        /// When `Some(t)`, only delete loose objects strictly older than `t` (Git grace window).
1601        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    // Git expires everything for `now` and `all`, including objects written this second.
1615    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            &params,
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/// Build the all-into-one repack pack for `roots`, stopping history at the `shallow`
1672/// boundary commits.
1673#[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
1736/// Objects a full repack writes: everything reachable from `roots` down to the
1737/// `shallow` boundary commits, plus every other packed and loose object when
1738/// `keep_unreachable` is set.
1739fn 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    // Roots arrive in hash-set order; sorting them makes the pack (and its name)
1747    // the same on every run.
1748    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
1771/// Append packed and loose objects not already selected for the repack send list.
1772fn 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
1805/// Packs in `pack_dir` without a `.keep` marker, from a single directory read.
1806fn 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
1879/// Remove pack artifacts in `pack_dir` except those belonging to `keep_pack`.
1880pub 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
1936/// Pack loose refs into `packed-refs` (files backend) or compact reftable stacks.
1937///
1938/// # Errors
1939///
1940/// Propagates ref-store, locking, and I/O failures.
1941pub 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    /// Write a reachable commit-graph file for this repository.
1953    ///
1954    /// # Errors
1955    ///
1956    /// See [`write_commit_graph`].
1957    pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
1958        write_commit_graph(self, opts)
1959    }
1960
1961    /// Write or refresh the multi-pack index for this repository's pack directory.
1962    ///
1963    /// # Errors
1964    ///
1965    /// See [`write_midx`].
1966    pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
1967        write_midx(self, opts)
1968    }
1969}
1970
1971/// Standalone object fsck helpers.
1972pub mod fsck {
1973    pub use crate::fsck_standalone::*;
1974}
1975
1976/// Repository-wide fsck (object store, connectivity, refs).
1977pub 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        // gc.auto = 300 allows ceil(300 / 256) = 2 sampled objects.
2061        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        // A process on this host that is no longer running.
2183        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        // Another host cannot be checked, so a recent entry blocks.
2190        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        // The same entry is ignored once it is older than 12 hours.
2200        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        // Cached ref/config paths can finish pack-refs or commit-graph in under 1 ms (0 in
2499        // millisecond buckets). Require repack timing plus evidence the other phases ran.
2500        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}