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};
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use std::time::{Instant, SystemTime};
22
23use crate::commit_graph_file::CommitGraphChain;
24use crate::commit_graph_write::{
25    atomic_replace_commit_graph, build_commit_graph_bytes_from_oid_maps,
26    collect_reachable_commit_oids_for_repository, load_commit_graph_commit_info,
27    remove_commit_graph_chain_dir, remove_commit_graph_files, BloomWriteStats,
28};
29use crate::config::ConfigSet;
30use crate::diff::bloom::BloomFilterSettings;
31use crate::environment::Environment;
32use crate::error::{ConfigError, Error, Result};
33use crate::fsck as repo_fsck;
34use crate::gc::{prune_loose_unreachable_with, LazyReachable, PruneStats, S_IFDIR, S_IFMT};
35use crate::git_date::approx::approxidate_careful_at;
36use crate::index_pack::{install_pack_bytes, install_pack_path, IngestPackOptions};
37use crate::midx::{
38    clear_pack_midx_state, midx_version_from_config, midx_write_version,
39    write_multi_pack_index_with_options, MidxBitmapWriteOutcome, WriteMultiPackIndexOptions,
40    WriteMultiPackIndexResult,
41};
42use crate::objects::{
43    oid_hash_map, tag_object_line_oid, CommitRef, FileMode, ObjectId, ObjectKind, OidHashSet,
44    TreeRefIter,
45};
46use crate::odb::Odb;
47use crate::odb::UnreachablePackRetention;
48use crate::pack_bitmap::{PackBitmapWriteOptions, PackBitmapWriter};
49use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
50use crate::pack_name_hash::pack_name_hash_child;
51use crate::pack_object_select::{
52    enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
53};
54use crate::pack_objects::{PackBuildOptions, SerializedDeltaPack};
55use crate::pack_rev::write_pack_rev_sidecar_for_index_path;
56use crate::prune_packed::{prune_packed_objects, PrunePackedOptions};
57use crate::reflog::{
58    expire_reflog_git_with_promises, load_gc_reflog_expire_config, reflog_refs_on_store,
59    ReflogExpireParams,
60};
61use crate::repack_pack::{write_repack_pack, RepackPackSettings};
62use crate::repo::Repository;
63use crate::shallow::load_shallow_boundaries;
64use time::OffsetDateTime;
65
66/// Options for [`repack_all()`].
67#[derive(Debug, Clone)]
68#[non_exhaustive]
69pub struct RepackOptions {
70    /// Report actions without writing packs or deleting files.
71    pub dry_run: bool,
72    /// After repack, write `objects/info/commit-graph` when the ODB is files-backed.
73    pub write_commit_graph: bool,
74    /// After repack, write a `.bitmap` sidecar for the new pack (files-backed only).
75    pub write_bitmap: bool,
76    /// When set, prune unreachable loose objects not newer than this instant after repack.
77    pub prune_older_than: Option<OffsetDateTime>,
78    /// Wall clock passed to maintenance helpers that require an explicit time (e.g. bitmap writes).
79    pub now: Option<OffsetDateTime>,
80    /// When true (the default), do not delete unreachable loose objects after repack (matches `git repack -ad`).
81    pub keep_unreachable: bool,
82    /// Worker threads for pack indexing (`None` reads `pack.threads` / CPU count).
83    pub threads: Option<usize>,
84    /// Override the delta settings of a full repack; `None` uses
85    /// [`PackBuildOptions::for_local_copy`] (`pack.window` / `pack.depth` from config).
86    ///
87    /// A full repack honors `delta`, `window`, `max_depth`, `reuse_deltas`, `reuse_objects`,
88    /// `use_ofs_delta` and `use_bitmaps`. Objects already in a pack keep their stored form
89    /// when the reuse flags allow it; only the rest go through the `window` search.
90    pub pack_build: Option<PackBuildOptions>,
91    /// Config/environment for ingest and pack threads (defaults to repo config).
92    pub merged_config: Option<ConfigSet>,
93    /// Process environment for config discovery and thread counts.
94    pub environment: Environment,
95    /// Pack only the reachable loose objects into a new pack, keeping existing packs (Git
96    /// `repack -d` without `-a`). Unreachable loose objects stay loose for the prune step.
97    /// The new pack is delta-compressed like a full repack, with bases taken only from the
98    /// loose objects it holds, so it is self-contained.
99    ///
100    /// When [`Self::write_bitmap`] is true and the repository already has pack files, bitmaps are
101    /// written via a multi-pack-index sidecar instead of a per-pack `.bitmap` on the new pack alone.
102    /// Bitmaps are omitted when known promises prevent a complete closure.
103    pub incremental: bool,
104    /// When true, [`gc()`] may skip repack on a single pack with no loose objects when every
105    /// packed object is still reachable and none would be dropped by the active prune policy.
106    /// Explicit [`repack_all`] / [`repack`] leave this false so maintenance still runs.
107    pub auto_skip: bool,
108    /// When true, recompute every delta instead of keeping the ones stored in packs
109    /// (`git repack -f`, `grit repack --force`). Whole objects that stay whole are
110    /// still copied verbatim.
111    pub force_recompress: bool,
112}
113
114impl Default for RepackOptions {
115    fn default() -> Self {
116        Self {
117            dry_run: false,
118            write_commit_graph: false,
119            write_bitmap: false,
120            prune_older_than: None,
121            now: None,
122            keep_unreachable: true,
123            threads: None,
124            pack_build: None,
125            merged_config: None,
126            environment: Environment::empty(),
127            incremental: false,
128            auto_skip: false,
129            force_recompress: false,
130        }
131    }
132}
133
134impl RepackOptions {
135    /// Start from [`Default::default`] with [`Self::dry_run`] set.
136    #[must_use]
137    pub fn with_dry_run(dry_run: bool) -> Self {
138        Self {
139            dry_run,
140            ..Self::default()
141        }
142    }
143}
144
145struct RepackDetail {
146    report: RepackReport,
147    pack_path: Option<PathBuf>,
148    prune_packed_loose: usize,
149    unreachable_loose: PruneStats,
150    unreachable_prune_ms: u64,
151    unreachable_prune_ran: bool,
152}
153
154/// Counters produced by [`repack`].
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156#[non_exhaustive]
157pub struct RepackReport {
158    /// New pack files installed (0 or 1 for all-into-one repack).
159    pub packs_written: usize,
160    /// Pack sidecar files removed from `objects/pack/` (excluding `.keep` and promisor packs).
161    pub packs_removed: usize,
162    /// Loose object files removed (`prune-packed` plus unreachable prune).
163    pub loose_removed: usize,
164    /// Objects written into the new pack.
165    pub objects: usize,
166}
167
168/// Extended counters and paths produced by [`repack_all`].
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
170#[non_exhaustive]
171pub struct RepackStats {
172    /// Loose object files before repack.
173    pub loose_before: usize,
174    /// Pack files before repack.
175    pub packs_before: usize,
176    /// Objects written into the new local pack, or estimated for a dry run.
177    /// `None` when no objects need packing.
178    pub objects_packed: Option<usize>,
179    /// Bytes in the new local pack, excluding retained packs (`None` on dry run).
180    pub pack_bytes: Option<u64>,
181    /// Path to the installed local pack, even when other packs are retained.
182    /// `None` on dry run or when nothing was packed.
183    pub pack_path: Option<PathBuf>,
184    /// Sidecar/index files removed from `objects/pack/`.
185    pub removed_pack_files: usize,
186    /// Loose objects removed because they were redundant with the new pack.
187    pub pruned_loose: usize,
188    /// Unreachable loose objects removed after repack when pruning is enabled.
189    pub unreachable_loose_pruned: usize,
190    /// Unreachable loose objects kept (grace window or concurrent safety).
191    pub unreachable_loose_kept: usize,
192    /// Wall time spent in unreachable loose-object pruning during this repack.
193    pub unreachable_prune_ms: u64,
194    /// True when unreachable loose-object pruning was executed (even if nothing was removed).
195    pub unreachable_prune_ran: bool,
196}
197
198/// Options for [`Repository::write_commit_graph`].
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct CommitGraphOptions {
202    /// When true, emit Bloom filter chunks for changed-path queries.
203    pub changed_paths: bool,
204    /// Report without writing `objects/info/commit-graph`.
205    pub dry_run: bool,
206}
207
208impl CommitGraphOptions {
209    /// Build options with an explicit changed-paths Bloom setting.
210    #[must_use]
211    pub fn with_changed_paths(changed_paths: bool) -> Self {
212        Self {
213            changed_paths,
214            ..Self::default()
215        }
216    }
217
218    /// Build options with [`Self::dry_run`] set.
219    #[must_use]
220    pub fn with_dry_run(dry_run: bool) -> Self {
221        Self {
222            dry_run,
223            ..Self::default()
224        }
225    }
226}
227
228/// Result of writing `objects/info/commit-graph`.
229#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub struct CommitGraphWriteReport {
232    /// Path to the monolithic commit-graph file.
233    pub path: PathBuf,
234    /// Commits encoded in the graph (`0` when [`Self::skipped`] is set).
235    pub commit_count: usize,
236    /// Whether a split `commit-graphs/` chain was removed.
237    pub removed_chain: bool,
238    /// Bloom generation counters when [`CommitGraphOptions::changed_paths`] was set and a
239    /// graph was written.
240    pub bloom_stats: Option<BloomWriteStats>,
241    /// Why no graph was written, when the repository rewrites parent lists.
242    pub skipped: Option<CommitGraphSkipReason>,
243}
244
245/// Why [`write_commit_graph`] wrote no graph.
246///
247/// A commit-graph stores each commit's parents as recorded in the commit object. When the
248/// repository overrides those parents, a graph would disagree with what Git sees: Git
249/// neither writes nor reads a graph in that state, and `git fsck` rejects one that exists.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251#[non_exhaustive]
252pub enum CommitGraphSkipReason {
253    /// `.git/shallow` lists boundary commits whose parents are cut off.
254    Shallow,
255    /// `info/grafts` overrides the parents of at least one commit.
256    Grafts,
257    /// Replace refs (`refs/replace/`) are present and enabled.
258    ReplaceRefs,
259}
260
261impl CommitGraphSkipReason {
262    /// Stable kebab-case identifier: `shallow`, `grafts` or `replace-refs`.
263    #[must_use]
264    pub fn as_str(self) -> &'static str {
265        match self {
266            Self::Shallow => "shallow",
267            Self::Grafts => "grafts",
268            Self::ReplaceRefs => "replace-refs",
269        }
270    }
271
272    /// Check whether the repository at `git_dir` rewrites parent lists.
273    ///
274    /// `git_dir` may be a linked worktree's git directory; shallow and graft files are read
275    /// from the common directory. `has_replace_refs` is only called when neither applies and
276    /// reports whether enabled replace refs exist.
277    pub(crate) fn detect(
278        git_dir: &Path,
279        has_replace_refs: impl FnOnce() -> Result<bool>,
280    ) -> Result<Option<Self>> {
281        let common = crate::repo::common_git_dir_for_config(git_dir);
282        if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
283            return Ok(Some(Self::Shallow));
284        }
285        if !crate::rev_parse::load_graft_parents(&common).is_empty() {
286            return Ok(Some(Self::Grafts));
287        }
288        Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
289    }
290
291    /// Like [`Self::detect`] for an open repository, honoring `core.useReplaceRefs`,
292    /// `GIT_NO_REPLACE_OBJECTS` and `GIT_REPLACE_REF_BASE`.
293    pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
294        Self::detect(repo.git_dir(), || {
295            let Some(base) = repo.replace_ref_base_if_enabled() else {
296                return Ok(false);
297            };
298            Ok(!repo.references().list(&base)?.is_empty())
299        })
300    }
301}
302
303/// Options for [`Repository::write_midx`].
304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
305#[non_exhaustive]
306pub struct MidxOptions {
307    /// Write a reachability `.bitmap` sidecar when the MIDX is a root file.
308    pub bitmap: bool,
309    /// Report without writing `multi-pack-index`.
310    pub dry_run: bool,
311    /// On-disk MIDX version (`1` or `2`). `None` uses `midx.version` from the repository
312    /// config, and v1 when that is unset, so every Git with multi-pack-index support can
313    /// read the result.
314    pub version: Option<u8>,
315}
316
317impl MidxOptions {
318    /// Build MIDX write options with an explicit bitmap sidecar flag.
319    #[must_use]
320    pub fn with_bitmap(bitmap: bool) -> Self {
321        Self {
322            bitmap,
323            ..Self::default()
324        }
325    }
326
327    /// Build options with [`Self::dry_run`] set.
328    #[must_use]
329    pub fn with_dry_run(dry_run: bool) -> Self {
330        Self {
331            dry_run,
332            ..Self::default()
333        }
334    }
335}
336
337/// Result of writing `objects/pack/multi-pack-index`.
338#[derive(Debug, Clone, PartialEq, Eq)]
339#[non_exhaustive]
340pub struct MidxWriteReport {
341    /// Path to the tip MIDX file.
342    pub path: PathBuf,
343    /// Pack indexes covered by the MIDX.
344    pub pack_count: usize,
345    /// Whether a reachability bitmap was written or skipped.
346    pub bitmap: MidxBitmapWriteOutcome,
347}
348
349/// Options for [`References::pack`](crate::refs::References::pack).
350#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
351#[non_exhaustive]
352pub struct PackRefsOptions {
353    /// When true, pack every packable ref (`git pack-refs --all`); otherwise only tags.
354    pub all: bool,
355    /// Report without rewriting `packed-refs` or deleting loose refs.
356    pub dry_run: bool,
357}
358
359impl PackRefsOptions {
360    /// Build pack-refs options matching `git pack-refs` / `--all`.
361    #[must_use]
362    pub fn with_all(all: bool) -> Self {
363        Self {
364            all,
365            ..Self::default()
366        }
367    }
368
369    /// Build options with [`Self::dry_run`] set.
370    #[must_use]
371    pub fn with_dry_run(dry_run: bool) -> Self {
372        Self {
373            dry_run,
374            ..Self::default()
375        }
376    }
377}
378
379/// Result of packing refs (files `packed-refs` or reftable compaction).
380#[derive(Debug, Clone, PartialEq, Eq)]
381#[non_exhaustive]
382pub struct PackRefsReport {
383    /// `packed-refs` path or the `reftable/` directory.
384    pub path: PathBuf,
385    /// Loose refs merged into the packed store.
386    pub refs_packed: usize,
387    /// Loose ref files removed (files backend) or tables merged (reftable).
388    pub refs_pruned: usize,
389}
390
391/// Options for [`gc()`].
392#[derive(Debug, Clone)]
393#[non_exhaustive]
394pub struct GcOptions {
395    /// Repack options applied before pruning unreachable objects.
396    pub repack: RepackOptions,
397    /// Expire reflogs using `gc.reflogExpire*` before repack/prune.
398    pub expire_reflog: bool,
399    /// Unix seconds for reflog expiry rules.
400    pub now_unix: i64,
401    /// When true, pack loose refs before other gc steps.
402    pub pack_refs: bool,
403    /// When true, write `objects/info/commit-graph` after repack/prune.
404    ///
405    /// Changed-path Bloom filters are kept when the existing graph has them and are not
406    /// added otherwise, matching `git gc`.
407    pub write_commit_graph: bool,
408}
409
410impl Default for GcOptions {
411    fn default() -> Self {
412        Self {
413            repack: RepackOptions {
414                auto_skip: true,
415                ..RepackOptions::default()
416            },
417            expire_reflog: true,
418            now_unix: 0,
419            pack_refs: true,
420            write_commit_graph: true,
421        }
422    }
423}
424
425impl GcOptions {
426    /// Run gc with repack dry-run control and an explicit wall-clock for reflog expiry.
427    #[must_use]
428    pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
429        Self {
430            repack: RepackOptions {
431                dry_run,
432                auto_skip: true,
433                ..RepackOptions::default()
434            },
435            expire_reflog: true,
436            now_unix,
437            ..Self::default()
438        }
439    }
440
441    /// Default repack with explicit reflog expiry policy and wall-clock.
442    #[must_use]
443    pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
444        Self {
445            repack: RepackOptions {
446                auto_skip: true,
447                ..RepackOptions::default()
448            },
449            expire_reflog,
450            now_unix,
451            ..Self::default()
452        }
453    }
454
455    /// Full maintenance run: pack-refs, reflog expiry, repack, prune, commit-graph.
456    #[must_use]
457    pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
458        Self {
459            repack: RepackOptions {
460                dry_run,
461                auto_skip: true,
462                ..RepackOptions::default()
463            },
464            expire_reflog: true,
465            now_unix,
466            pack_refs: true,
467            write_commit_graph: true,
468            ..Self::default()
469        }
470    }
471}
472
473impl PruneOptions {
474    /// Prune with an optional explicit cutoff (`None` uses `gc.pruneExpire`).
475    #[must_use]
476    pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
477        Self {
478            older_than,
479            ..Self::default()
480        }
481    }
482}
483
484/// Object ids that must stay reachable during repack, prune, and fsck walks.
485///
486/// Includes ref tips, symbolic `HEAD`, every reflog `old`/`new` oid, and every
487/// blob/tree oid recorded in the index (including unmerged stages; gitlinks are excluded).
488///
489/// # Errors
490///
491/// Propagates ref, reflog, or index load failures.
492pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
493    repo_fsck::reachability_roots(repo)
494}
495
496pub(crate) fn bloom_settings_for_commit_graph_write(
497    config: &ConfigSet,
498    changed_paths: bool,
499) -> BloomFilterSettings {
500    let mut bloom = BloomFilterSettings::default();
501    if !changed_paths {
502        return bloom;
503    }
504    if let Some(v) = config
505        .get("commitgraph.changedpathsversion")
506        .and_then(|s| s.trim().parse::<i32>().ok())
507    {
508        if v == 2 {
509            bloom.hash_version = 2;
510        } else if v == 1 {
511            bloom.hash_version = 1;
512        }
513    }
514    bloom
515}
516
517/// Whether the current commit-graph carries changed-path Bloom filters.
518///
519/// Maintenance rewrites keep Bloom filters when the graph already has them and otherwise
520/// write without them, as Git's gc does: computing filters from scratch diffs every
521/// commit's tree and costs far more than the rest of the graph.
522fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
523    repo.odb()
524        .files_objects_dir()
525        .and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
526        .is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
527}
528
529pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
530    config
531        .get("commitgraph.generationversion")
532        .and_then(|s| s.trim().parse::<i32>().ok())
533        .map(|v| v >= 2)
534        .unwrap_or(true)
535}
536
537/// Write a monolithic reachable commit-graph under `objects/info/commit-graph`.
538///
539/// Reachable commits are discovered from [`Repository::refs`](crate::repo::Repository::refs)
540/// (reftable, packed-refs, and linked-worktree visibility), not by scanning `git_dir/refs` alone.
541///
542/// In a shallow repository, with `info/grafts`, or with enabled replace refs, nothing is
543/// written and [`CommitGraphWriteReport::skipped`] says why (see [`CommitGraphSkipReason`]).
544/// Git only skips the write in that state, but `git fsck` still verifies a graph that is
545/// already there, so an existing graph and split chain are deleted as well (except in
546/// [`CommitGraphOptions::dry_run`]).
547///
548/// # Errors
549///
550/// Returns [`Error::UnsupportedObjectStore`] for non-files ODB backends, or errors from graph
551/// generation and atomic I/O.
552pub fn write_commit_graph(
553    repo: &Repository,
554    opts: &CommitGraphOptions,
555) -> Result<CommitGraphWriteReport> {
556    let odb = repo.odb();
557    odb.require_files_primary("commit-graph write")?;
558    let objects_dir = odb
559        .files_objects_dir()
560        .ok_or(Error::UnsupportedObjectStore {
561            operation: "commit-graph write",
562        })?;
563    let path = objects_dir.join("info").join("commit-graph");
564
565    if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
566        let removed_chain = if opts.dry_run {
567            false
568        } else {
569            let removed = remove_commit_graph_files(objects_dir)?;
570            repo.caches().invalidate_commit_graph_cache();
571            removed
572        };
573        return Ok(CommitGraphWriteReport {
574            path,
575            commit_count: 0,
576            removed_chain,
577            bloom_stats: None,
578            skipped: Some(reason),
579        });
580    }
581
582    let config = repo.config()?;
583    let bloom = bloom_settings_for_commit_graph_write(&config, opts.changed_paths);
584    let write_generation = commit_graph_writes_generation_data(&config);
585
586    let chain = CommitGraphChain::try_load(objects_dir).ok().flatten();
587
588    let commits = collect_reachable_commit_oids_for_repository(repo)?;
589    let mut sorted: Vec<ObjectId> = commits.into_iter().collect();
590    sorted.sort();
591    let commit_count = sorted.len();
592
593    if opts.dry_run {
594        return Ok(CommitGraphWriteReport {
595            path,
596            commit_count,
597            removed_chain: false,
598            bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
599            skipped: None,
600        });
601    }
602
603    let mut infos = oid_hash_map();
604    for oid in &sorted {
605        infos.insert(*oid, load_commit_graph_commit_info(odb, *oid)?);
606    }
607
608    let mut existing_filters = oid_hash_map();
609    let mut upgraded_filters = oid_hash_map();
610    if opts.changed_paths {
611        if let Some(ref chain) = chain {
612            for oid in &sorted {
613                if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
614                    existing_filters.insert(*oid, bytes);
615                } else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
616                    upgraded_filters.insert(*oid, bytes);
617                }
618            }
619        }
620    }
621
622    let (bytes, bloom_stats) = build_commit_graph_bytes_from_oid_maps(
623        &sorted,
624        &infos,
625        odb,
626        opts.changed_paths,
627        &bloom,
628        None,
629        &[],
630        None,
631        &existing_filters,
632        &upgraded_filters,
633        write_generation,
634    )?;
635
636    let written_path = atomic_replace_commit_graph(objects_dir, &bytes)?;
637    let removed_chain = remove_commit_graph_chain_dir(objects_dir);
638    repo.caches().invalidate_commit_graph_cache();
639
640    Ok(CommitGraphWriteReport {
641        path: written_path,
642        commit_count,
643        removed_chain,
644        bloom_stats: opts.changed_paths.then_some(bloom_stats),
645        skipped: None,
646    })
647}
648
649/// Write or refresh `objects/pack/multi-pack-index` for the repository.
650///
651/// The on-disk version comes from [`MidxOptions::version`], then `midx.version`, then v1.
652///
653/// # Errors
654///
655/// Returns [`Error::UnsupportedObjectStore`] for non-files backends, [`Error::Config`] when
656/// `midx.version` is not an integer, [`crate::error::MidxError::UnknownWriteVersion`] (as
657/// [`Error::Midx`]) when the version is not `1` or `2`, or MIDX write failures.
658pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
659    let odb = repo.odb();
660    odb.require_files_primary("midx write")?;
661    let requested = match opts.version {
662        Some(v) => Some(v),
663        None => midx_version_from_config(&*repo.config()?)?,
664    };
665    let version = Some(midx_write_version(requested)?);
666    let objects_dir = odb
667        .files_objects_dir()
668        .ok_or(Error::UnsupportedObjectStore {
669            operation: "midx write",
670        })?;
671    let pack_dir = objects_dir.join("pack");
672    let pack_count = fs::read_dir(&pack_dir)
673        .map(|rd| {
674            rd.filter_map(|e| e.ok())
675                .filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
676                .filter(|e| e.path().with_extension("pack").is_file())
677                .count()
678        })
679        .unwrap_or(0);
680
681    let path = pack_dir.join("multi-pack-index");
682    if opts.dry_run {
683        return Ok(MidxWriteReport {
684            path,
685            pack_count,
686            bitmap: MidxBitmapWriteOutcome::NotRequested,
687        });
688    }
689    let write_opts = WriteMultiPackIndexOptions {
690        write_bitmap: opts.bitmap,
691        version,
692        ..WriteMultiPackIndexOptions::default()
693    };
694    let WriteMultiPackIndexResult { bitmap } =
695        write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
696    Ok(MidxWriteReport {
697        path,
698        pack_count,
699        bitmap,
700    })
701}
702
703/// Options for [`prune_loose_objects`].
704#[derive(Debug, Clone, Default)]
705#[non_exhaustive]
706pub struct PruneOptions {
707    /// When set, only delete unreachable loose objects strictly older than this time.
708    ///
709    /// When `None`, uses `gc.pruneExpire` from repository config (default `2.weeks.ago`).
710    pub older_than: Option<SystemTime>,
711    /// Report without deleting objects.
712    pub dry_run: bool,
713}
714
715/// Wall-clock milliseconds for each phase of [`gc`].
716#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
717#[non_exhaustive]
718pub struct GcPhaseTimings {
719    /// [`pack_refs`] when run as part of gc.
720    pub pack_refs_ms: u64,
721    /// Reflog expiry before repack.
722    pub reflog_ms: u64,
723    /// Repack work excluding embedded unreachable prune.
724    pub repack_ms: u64,
725    /// Unreachable loose-object prune (during repack or standalone).
726    pub prune_ms: u64,
727    /// Commit-graph write after repack.
728    pub commit_graph_ms: u64,
729}
730
731/// Result of [`gc`].
732#[derive(Debug, Clone, Default, PartialEq, Eq)]
733pub struct GcStats {
734    /// Loose refs packed during gc (`None` when skipped).
735    pub pack_refs: Option<PackRefsReport>,
736    /// Counters from the repack phase.
737    pub repack: RepackStats,
738    /// Counters from loose-object pruning after repack.
739    pub prune: PruneStats,
740    /// True when full gc ran unreachable loose-object pruning during repack.
741    pub prune_ran: bool,
742    /// Monolithic commit-graph written after repack/prune (`None` when skipped).
743    pub commit_graph: Option<CommitGraphWriteReport>,
744    /// Reflog lines removed by expiry during gc.
745    pub reflog_entries_pruned: usize,
746    /// Per-phase wall times for the gc orchestration.
747    pub phase_timings: GcPhaseTimings,
748}
749
750/// Repack local objects into a single new pack and drop redundant storage.
751///
752/// Mirrors a minimal `git repack -ad`: one pack for [`reachability_roots`](crate::fsck::reachability_roots), optional `.rev` /
753/// `.bitmap` sidecars, delete other packs, [`prune_packed_objects`], and optional unreachable
754/// loose-object pruning. Existing promisor packs and all their sidecars are retained
755/// separately, as are ordinary packs holding promised objects unavailable in those
756/// promisor packs. Promised objects are excluded from the local pack; missing promised
757/// targets never trigger remote reads. Bitmaps are omitted when promises prevent a
758/// complete closure. Dry runs and incremental repacks use the same promise boundary.
759///
760/// # Errors
761///
762/// Propagates pack build/install failures, unsupported custom ODB backends, and I/O errors.
763pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
764    Ok(repack_detail(repo, opts)?.report)
765}
766
767fn repack_detail(repo: &Repository, opts: &RepackOptions) -> Result<RepackDetail> {
768    let odb = repo.odb();
769    odb.require_files_primary("repack")?;
770    let objects_dir = odb
771        .files_objects_dir()
772        .ok_or(Error::UnsupportedObjectStore {
773            operation: "repack",
774        })?;
775    let pack_dir = objects_dir.join("pack");
776
777    let mut report = RepackReport::default();
778    let loose_before = count_loose_objects(odb)?;
779    let packs_before = count_pack_files(&pack_dir);
780
781    let roots = collect_maintenance_object_roots(repo)?;
782    if roots.is_empty() && loose_before == 0 && packs_before == 0 {
783        return Ok(RepackDetail {
784            report,
785            pack_path: None,
786            prune_packed_loose: 0,
787            unreachable_loose: PruneStats::default(),
788            unreachable_prune_ms: 0,
789            unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
790        });
791    }
792
793    let cfg = opts
794        .merged_config
795        .clone()
796        .or_else(|| repo.config().ok().map(|c| (*c).clone()));
797    let mut pack_opts = opts
798        .pack_build
799        .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
800    if opts.force_recompress {
801        pack_opts.reuse_deltas = false;
802    }
803
804    let run_incremental = opts.incremental;
805    let shallow = load_shallow_boundaries(repo.git_dir());
806    let reachable = LazyReachable::new(odb, &roots, &shallow);
807
808    let skip_repack = !opts.force_recompress
809        && !opts.write_bitmap
810        && !opts.write_commit_graph
811        && should_auto_skip_repack(
812            loose_before,
813            packs_before,
814            run_incremental,
815            opts.auto_skip,
816            cfg.as_ref(),
817        )
818        && !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
819
820    if skip_repack {
821        // Nothing is loose, but pruning still validates the roots without walking
822        // their closure. A missing local root must not become a successful no-op.
823        reachable.require_roots_exist()?;
824        return Ok(RepackDetail {
825            report,
826            pack_path: None,
827            prune_packed_loose: 0,
828            unreachable_loose: PruneStats::default(),
829            unreachable_prune_ms: 0,
830            unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
831        });
832    }
833    let promised = reachable.promised()?;
834    let partial_clone = !promised.is_empty()
835        || cfg.as_ref().is_some_and(|config| {
836            crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config)
837        });
838    if partial_clone {
839        // Validate local reachability without giving the pack enumerator a chance to
840        // lazy-fetch genuinely missing local objects in a partial clone.
841        reachable.get()?;
842    }
843    if opts.dry_run {
844        report.objects = if run_incremental {
845            reachable_loose_objects(odb, &reachable)?.len()
846        } else {
847            if !opts.keep_unreachable {
848                reachable
849                    .get()?
850                    .iter()
851                    .filter(|oid| !promised.contains(oid))
852                    .count()
853            } else {
854                repack_object_list(odb, &roots, &shallow, true, pack_opts.use_bitmaps, promised)?
855                    .send
856                    .len()
857            }
858        };
859        return Ok(RepackDetail {
860            report,
861            pack_path: None,
862            prune_packed_loose: 0,
863            unreachable_loose: PruneStats::default(),
864            unreachable_prune_ms: 0,
865            unreachable_prune_ran: false,
866        });
867    }
868
869    let retained_packs = if run_incremental {
870        HashSet::new()
871    } else {
872        packs_holding_available_promises(odb, promised)?
873    };
874    let built_pack = if run_incremental {
875        let (loose, name_hashes) = named_reachable_loose_objects(odb, &reachable, &roots)?;
876        write_staged_repack_pack(
877            odb,
878            &pack_dir,
879            &loose,
880            &name_hashes,
881            &pack_opts,
882            opts.threads,
883            cfg.as_ref(),
884        )?
885    } else {
886        build_repack_pack(
887            odb,
888            &pack_dir,
889            &roots,
890            &shallow,
891            opts.keep_unreachable,
892            &pack_opts,
893            opts.threads,
894            cfg.as_ref(),
895            promised,
896        )?
897    };
898    let pack_bytes_len = built_pack
899        .staging_path
900        .as_ref()
901        .and_then(|p| std::fs::metadata(p).ok())
902        .map(|m| m.len() as usize)
903        .unwrap_or_else(|| built_pack.bytes.len());
904    let mut prune_packed_loose = 0usize;
905    let mut unreachable_loose = PruneStats::default();
906    let mut installed_pack = None;
907    let pack_trailer_len = 12 + odb.hash_algo().len();
908    if pack_bytes_len > pack_trailer_len {
909        clear_pack_midx_state(&pack_dir)?;
910
911        let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
912        let ingest_opts = IngestPackOptions {
913            fix_thin: false,
914            threads: opts.threads,
915            merged_config: cfg.clone(),
916            environment: opts.environment.clone(),
917            // Locally built pack: trailer checked at write time; index rows match the stream.
918            skip_post_index_verify: trusted_rows.is_some(),
919            trusted_index_rows: trusted_rows,
920            ..IngestPackOptions::default()
921        };
922        let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
923            install_pack_path(staging, odb, &ingest_opts)?
924        } else {
925            install_pack_bytes(built_pack.bytes, odb, &ingest_opts)?
926        };
927        report.packs_written = 1;
928        report.objects = ingested.object_ids.len();
929        let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
930            operation: "repack",
931        })?;
932
933        let idx_path = pack_path.with_extension("idx");
934        if cfg
935            .as_ref()
936            .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
937        {
938            write_pack_rev_sidecar_for_index_path(&idx_path)?;
939        }
940
941        if opts.write_bitmap && promised.is_empty() {
942            if opts.incremental && packs_before > 0 {
943                // Per-pack bitmaps require reachability closure within one pack; incremental
944                // repack keeps older packs and adds a new layer. Write a multi-pack-index
945                // bitmap covering all packs instead of a sidecar on the new pack alone.
946                let version = match cfg.as_ref() {
947                    Some(c) => midx_version_from_config(c)?,
948                    None => None,
949                };
950                let midx_opts = WriteMultiPackIndexOptions {
951                    write_bitmap: true,
952                    version,
953                    ..WriteMultiPackIndexOptions::default()
954                };
955                let WriteMultiPackIndexResult { bitmap: _ } =
956                    write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
957            } else {
958                let bitmap_opts = PackBitmapWriteOptions::default();
959                let now = opts
960                    .now
961                    .map(offset_to_system_time)
962                    .unwrap_or(SystemTime::UNIX_EPOCH);
963                PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now)
964                    .map_err(|e| Error::CorruptObject(e.to_string()))?;
965            }
966        }
967
968        let retention = if opts.keep_unreachable {
969            UnreachablePackRetention::KeepAll
970        } else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
971            UnreachablePackRetention::KeepGraceProtected(cutoff)
972        } else {
973            UnreachablePackRetention::DropAll
974        };
975        // Incremental repack keeps every existing pack, so nothing is superseded.
976        if !opts.incremental {
977            odb.loosen_unreachable_from_superseded_packs(
978                &pack_dir,
979                &pack_path,
980                &reachable,
981                retention,
982                &retained_packs,
983            )?;
984        }
985
986        // Drop cached pack/index mmaps before deleting superseded pack artifacts (Windows).
987        odb.invalidate_packs();
988
989        report.packs_removed = if opts.incremental {
990            0
991        } else {
992            remove_superseded_pack_files_retaining(&pack_dir, &pack_path, &retained_packs)?
993        };
994
995        prune_packed_loose =
996            prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
997
998        installed_pack = Some(pack_path);
999
1000        if opts.write_commit_graph {
1001            write_commit_graph(
1002                repo,
1003                &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
1004                    repo,
1005                )),
1006            )?;
1007        }
1008    }
1009
1010    let mut unreachable_prune_ms = 0u64;
1011    let mut unreachable_prune_ran = false;
1012    if !opts.keep_unreachable {
1013        let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
1014        let prune_start = Instant::now();
1015        unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
1016        unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
1017        unreachable_prune_ran = true;
1018    }
1019
1020    report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
1021    Ok(RepackDetail {
1022        report,
1023        pack_path: installed_pack,
1024        prune_packed_loose,
1025        unreachable_loose,
1026        unreachable_prune_ms,
1027        unreachable_prune_ran,
1028    })
1029}
1030
1031/// Like [`repack`], with legacy [`RepackStats`] fields for the CLI.
1032pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
1033    let odb = repo.odb();
1034    let objects_dir = odb
1035        .files_objects_dir()
1036        .ok_or(Error::UnsupportedObjectStore {
1037            operation: "repack",
1038        })?;
1039    let pack_dir = objects_dir.join("pack");
1040    let mut stats = RepackStats {
1041        loose_before: count_loose_objects(odb)?,
1042        packs_before: count_pack_files(&pack_dir),
1043        ..RepackStats::default()
1044    };
1045    if opts.dry_run {
1046        let count = repack_detail(repo, opts)?.report.objects;
1047        stats.objects_packed = (count > 0).then_some(count);
1048        return Ok(stats);
1049    }
1050    let detail = repack_detail(repo, opts)?;
1051    let report = detail.report;
1052    stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
1053    stats.removed_pack_files = report.packs_removed;
1054    stats.pruned_loose = detail.prune_packed_loose;
1055    stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
1056    stats.unreachable_loose_kept = detail.unreachable_loose.kept;
1057    stats.unreachable_prune_ms = detail.unreachable_prune_ms;
1058    stats.unreachable_prune_ran = detail.unreachable_prune_ran;
1059    stats.pack_path = detail.pack_path;
1060    stats.pack_bytes = stats
1061        .pack_path
1062        .as_ref()
1063        .and_then(|p| p.metadata().ok().map(|m| m.len()));
1064    Ok(stats)
1065}
1066
1067fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
1068    SystemTime::UNIX_EPOCH
1069        + std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
1070}
1071
1072fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
1073    match policy {
1074        GcLoosePrunePolicy::Disabled => {
1075            repack.keep_unreachable = true;
1076        }
1077        GcLoosePrunePolicy::Enabled { keep_newer_than } => {
1078            repack.keep_unreachable = false;
1079            repack.prune_older_than = keep_newer_than.map(|t| {
1080                let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
1081                OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
1082                    .unwrap_or(OffsetDateTime::UNIX_EPOCH)
1083            });
1084        }
1085    }
1086}
1087
1088fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> bool {
1089    if let Some(Ok(v)) = config.get_bool("repack.writeBitmaps") {
1090        return v;
1091    }
1092    repo.work_tree().is_none()
1093}
1094
1095/// Loose objects reachable from the maintenance roots: what Git's incremental `repack -d`
1096/// packs. Unreachable loose objects stay loose so `gc.pruneExpire` can expire them.
1097fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1098    let loose = odb.enumerate_local_loose_objects()?;
1099    if loose.is_empty() {
1100        return Ok(Vec::new());
1101    }
1102    let promised = reachable.promised()?;
1103    let reachable = reachable.get()?;
1104    Ok(loose
1105        .into_iter()
1106        .map(|(oid, _)| oid)
1107        .filter(|oid| reachable.contains(oid) && !promised.contains(oid))
1108        .collect())
1109}
1110
1111/// [`reachable_loose_objects`] in pack order, with the path name hash of each tree and
1112/// blob found under a loose commit so versions of one file meet in the delta window.
1113///
1114/// Only loose objects are read: the walk follows loose commits from `roots` (newest
1115/// first) and descends into loose trees, stopping wherever history or a subtree is
1116/// already packed. Objects it does not reach keep their place after the walked ones
1117/// without a name hash.
1118fn named_reachable_loose_objects(
1119    odb: &Odb,
1120    reachable: &LazyReachable<'_>,
1121    roots: &[ObjectId],
1122) -> Result<(Vec<ObjectId>, HashMap<ObjectId, u32>)> {
1123    let loose = reachable_loose_objects(odb, reachable)?;
1124    let mut pending: OidHashSet = loose.iter().copied().collect();
1125    let mut order = Vec::with_capacity(loose.len());
1126    let mut names = HashMap::new();
1127    let mut commits = Vec::new();
1128    let mut root_trees = Vec::new();
1129    let mut stack: Vec<ObjectId> = roots
1130        .iter()
1131        .filter(|oid| pending.contains(*oid))
1132        .copied()
1133        .collect();
1134    let mut seen = OidHashSet::default();
1135    while let Some(oid) = stack.pop() {
1136        if !seen.insert(oid) {
1137            continue;
1138        }
1139        match odb.read_info(&oid)?.kind {
1140            ObjectKind::Commit => {
1141                let obj = odb.read(&oid)?;
1142                let commit = CommitRef::parse(&obj.data)?;
1143                commits.push((
1144                    commit.committer_unix_seconds_for_ordering(),
1145                    oid,
1146                    commit.tree(),
1147                ));
1148                stack.extend(commit.parents().iter().filter(|p| pending.contains(*p)));
1149            }
1150            ObjectKind::Tag => {
1151                let obj = odb.read(&oid)?;
1152                pending.remove(&oid);
1153                order.push(oid);
1154                stack.extend(tag_object_line_oid(&obj.data).filter(|t| pending.contains(t)));
1155            }
1156            ObjectKind::Tree => root_trees.push(oid),
1157            ObjectKind::Blob => {}
1158        }
1159    }
1160    commits.sort_by_key(|&(time, _, _)| std::cmp::Reverse(time));
1161    for &(_, oid, _) in &commits {
1162        pending.remove(&oid);
1163        order.push(oid);
1164    }
1165    let hash_len = odb.hash_algo().len();
1166    let mut trees: Vec<(ObjectId, Option<u32>)> = Vec::new();
1167    for tree in commits.iter().map(|&(_, _, tree)| tree).chain(root_trees) {
1168        trees.push((tree, None));
1169        while let Some((tree, name_hash)) = trees.pop() {
1170            if !pending.remove(&tree) {
1171                continue;
1172            }
1173            order.push(tree);
1174            if let Some(hash) = name_hash {
1175                names.insert(tree, hash);
1176            }
1177            let obj = odb.read(&tree)?;
1178            for entry in TreeRefIter::with_oid_len(&obj.data, hash_len) {
1179                let entry = entry?;
1180                if FileMode::new(entry.mode).is_gitlink() || !pending.contains(&entry.oid) {
1181                    continue;
1182                }
1183                let hash = pack_name_hash_child(name_hash, entry.name);
1184                if entry.mode & S_IFMT == S_IFDIR {
1185                    trees.push((entry.oid, Some(hash)));
1186                } else {
1187                    pending.remove(&entry.oid);
1188                    order.push(entry.oid);
1189                    names.insert(entry.oid, hash);
1190                }
1191            }
1192        }
1193    }
1194    order.extend(loose.into_iter().filter(|oid| pending.contains(oid)));
1195    Ok((order, names))
1196}
1197
1198/// Whether maintenance should skip rebuilding packs when [`RepackOptions::auto_skip`] is set.
1199///
1200/// Used by [`gc`]: skip when a single pack already holds all objects and nothing is loose.
1201/// Multiple packs never skip (consolidation required), even when `gc.autoPackLimit` is zero.
1202fn should_auto_skip_repack(
1203    loose_objects: usize,
1204    pack_count: usize,
1205    incremental: bool,
1206    auto_skip: bool,
1207    _config: Option<&ConfigSet>,
1208) -> bool {
1209    if !auto_skip || incremental {
1210        return false;
1211    }
1212    if pack_count > 1 {
1213        return false;
1214    }
1215    if pack_count == 1 {
1216        return loose_objects == 0;
1217    }
1218    loose_objects == 0
1219}
1220
1221/// True when skipping repack would leave unreachable objects in pack storage that repack would
1222/// remove under `opts` (for example expired unreachable commits when `gc.pruneExpire=now`).
1223///
1224/// The reachability walk only runs once some pack is old enough to have objects dropped.
1225fn skipping_repack_would_retain_prunable_packed_objects(
1226    odb: &Odb,
1227    pack_dir: &Path,
1228    reachable: &LazyReachable<'_>,
1229    opts: &RepackOptions,
1230) -> Result<bool> {
1231    if opts.keep_unreachable {
1232        return Ok(false);
1233    }
1234    let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1235        UnreachablePackRetention::KeepGraceProtected(cutoff)
1236    } else {
1237        UnreachablePackRetention::DropAll
1238    };
1239    let indexes = odb.local_pack_indexes()?;
1240    for idx in indexes {
1241        if idx.is_promisor || pack_has_keep_marker(pack_dir, &idx.pack_path) {
1242            continue;
1243        }
1244        let pack_mtime = fs::metadata(&idx.pack_path)
1245            .and_then(|m| m.modified())
1246            .unwrap_or(SystemTime::UNIX_EPOCH);
1247        let drop_unreachable_from_pack = match retention {
1248            UnreachablePackRetention::KeepAll => false,
1249            UnreachablePackRetention::DropAll => true,
1250            UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1251        };
1252        if !drop_unreachable_from_pack {
1253            continue;
1254        }
1255        let reachable = reachable.get()?;
1256        for entry in idx.iter() {
1257            let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1258                continue;
1259            };
1260            if !reachable.contains(&oid) {
1261                return Ok(true);
1262            }
1263        }
1264    }
1265    Ok(false)
1266}
1267
1268/// Delete unreachable loose objects using `gc.pruneExpire` or an explicit cutoff.
1269///
1270/// # Errors
1271///
1272/// Propagates config parse failures, unsupported ODB backends, and I/O errors.
1273pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1274    let odb = repo.odb();
1275    odb.require_files_primary("prune")?;
1276    let cfg = repo.config()?;
1277    let now = gc_now_unix(0);
1278    let keep_newer_than = match opts.older_than {
1279        Some(t) => Some(t),
1280        None => match gc_loose_prune_policy(&cfg, now)? {
1281            GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1282            GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1283        },
1284    };
1285    let roots = collect_maintenance_object_roots(repo)?;
1286    let shallow = load_shallow_boundaries(repo.git_dir());
1287    prune_loose_unreachable_with(
1288        odb,
1289        &LazyReachable::new(odb, &roots, &shallow),
1290        keep_newer_than,
1291        opts.dry_run,
1292    )
1293}
1294
1295fn duration_ms(d: std::time::Duration) -> u64 {
1296    d.as_millis() as u64
1297}
1298
1299/// Run pack-refs, reflog expiry, repack, prune, and commit-graph maintenance.
1300///
1301/// Unless [`RepackOptions::dry_run`] is set, holds the repository's `gc.pid` lock for the
1302/// whole run, so it never overlaps another grit or Git gc (see [`GcLock`]).
1303///
1304/// # Errors
1305///
1306/// [`Error::MaintenanceRunning`] when another process holds the `gc.pid` lock, plus failures
1307/// from each maintenance step.
1308pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1309    repo.odb().require_files_primary("gc")?;
1310    let now = gc_now_unix(opts.now_unix);
1311    let _lock = if opts.repack.dry_run {
1312        None
1313    } else {
1314        match GcLock::try_acquire(repo, now)? {
1315            GcLockAttempt::Acquired(lock) => Some(lock),
1316            GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1317        }
1318    };
1319    gc_locked(repo, opts, now)
1320}
1321
1322/// Why automatic maintenance is due, as decided by [`auto_gc_needed`].
1323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1324pub enum AutoGcReason {
1325    /// The loose-object estimate is above `gc.auto`. [`gc_auto`] packs the reachable loose
1326    /// objects into one new pack and leaves existing packs alone.
1327    TooManyLoose {
1328        /// Loose objects in the sampled fan-out directory (`objects/17`) times 256.
1329        estimate: usize,
1330        /// Effective `gc.auto` threshold.
1331        limit: usize,
1332    },
1333    /// There are more non-`.keep` packs than `gc.autoPackLimit`. [`gc_auto`] consolidates
1334    /// every pack into one.
1335    TooManyPacks {
1336        /// Local packs without a `.keep` marker.
1337        count: usize,
1338        /// Effective `gc.autoPackLimit`.
1339        limit: usize,
1340    },
1341}
1342
1343/// Fan-out directory sampled to estimate the loose-object count, as Git documents for `gc.auto`.
1344const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1345
1346/// Decide whether automatic maintenance is due, using Git's `gc --auto` heuristics.
1347///
1348/// Pack consolidation wins when the number of local packs without a `.keep` marker is above
1349/// [`ConfigSet::gc_auto_pack_limit`]. Otherwise the loose-object count is estimated from a
1350/// single fan-out directory (`objects/17`) and compared with [`ConfigSet::gc_auto_threshold`],
1351/// so the check costs two directory reads however large the repository is. `gc.auto <= 0`
1352/// turns both checks off. Repositories without a files object store never need it.
1353///
1354/// This does not look at `maintenance.auto`; callers that start maintenance after a command
1355/// check [`ConfigSet::maintenance_auto`] themselves.
1356///
1357/// # Errors
1358///
1359/// Config load failures and I/O errors other than a missing directory.
1360pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1361    let cfg = repo.config()?;
1362    let Some(threshold) = cfg.gc_auto_threshold() else {
1363        return Ok(None);
1364    };
1365    let Some(objects_dir) = repo.odb().files_objects_dir() else {
1366        return Ok(None);
1367    };
1368    if let Some(limit) = cfg.gc_auto_pack_limit() {
1369        let count = count_pack_files(&objects_dir.join("pack"));
1370        if count > limit {
1371            return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1372        }
1373    }
1374    let name_len = repo.odb().hash_algo().hex_len() - 2;
1375    let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1376    if sampled > threshold.div_ceil(256) {
1377        return Ok(Some(AutoGcReason::TooManyLoose {
1378            estimate: sampled.saturating_mul(256),
1379            limit: threshold,
1380        }));
1381    }
1382    Ok(None)
1383}
1384
1385/// Count entries of one loose-object fan-out directory that look like object files.
1386fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1387    let entries = match fs::read_dir(dir) {
1388        Ok(entries) => entries,
1389        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1390        Err(e) => return Err(Error::Io(e)),
1391    };
1392    Ok(entries
1393        .filter_map(|e| e.ok())
1394        .filter(|e| {
1395            let name = e.file_name();
1396            let name = name.as_encoded_bytes();
1397            name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1398        })
1399        .count())
1400}
1401
1402/// Result of [`gc_auto`].
1403#[derive(Debug, Clone, PartialEq, Eq)]
1404#[non_exhaustive]
1405pub enum AutoGcOutcome {
1406    /// Neither threshold is exceeded (or `gc.auto` is off); nothing ran.
1407    NotNeeded,
1408    /// Another process holds the `gc.pid` lock; nothing ran.
1409    AlreadyRunning(GcLockHolder),
1410    /// The `pre-auto-gc` hook exited non-zero; nothing ran.
1411    DeclinedByHook {
1412        /// Exit status of the hook.
1413        status: i32,
1414        /// What the hook wrote, when hook output is captured ([`Repository::hook_stdio`]).
1415        output: crate::hooks::HookOutput,
1416    },
1417    /// Maintenance ran.
1418    Ran {
1419        /// Which threshold triggered the run.
1420        reason: AutoGcReason,
1421        /// What each step did.
1422        stats: Box<GcStats>,
1423    },
1424}
1425
1426/// Run maintenance only when Git's `gc --auto` heuristics say it is due ([`auto_gc_needed`]).
1427///
1428/// When too many loose objects triggered the run, the reachable ones are packed into one new
1429/// pack without rewriting existing packs and without a bitmap, so the cost scales with the
1430/// loose objects rather than the repository. Unreachable loose objects stay loose until
1431/// `gc.pruneExpire` lets the prune step delete them, as in Git. Too many packs runs a full consolidating [`gc`]. Either way the
1432/// other `opts` steps (pack-refs, reflog expiry, unreachable prune under `gc.pruneExpire`,
1433/// commit-graph) run as in [`gc`].
1434///
1435/// Before doing any work this takes the `gc.pid` lock ([`GcLock`]) and runs the `pre-auto-gc`
1436/// hook; a held lock or a failing hook ends the call without changes. With
1437/// [`RepackOptions::dry_run`] neither happens and the returned stats describe the planned work.
1438///
1439/// The library never forks: callers that honor [`ConfigSet::gc_auto_detach`] start a separate
1440/// process that calls this function.
1441///
1442/// # Errors
1443///
1444/// Config, hook spawn, lock and I/O failures, and any error from the maintenance steps.
1445pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1446    let Some(reason) = auto_gc_needed(repo)? else {
1447        return Ok(AutoGcOutcome::NotNeeded);
1448    };
1449    let now = gc_now_unix(opts.now_unix);
1450    let dry_run = opts.repack.dry_run;
1451    let _lock = if dry_run {
1452        None
1453    } else {
1454        match GcLock::try_acquire(repo, now)? {
1455            GcLockAttempt::Acquired(lock) => Some(lock),
1456            GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1457        }
1458    };
1459    if !dry_run {
1460        if let crate::hooks::HookResult::Failed { status, output } =
1461            crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1462        {
1463            return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1464        }
1465    }
1466    let mut run_opts = opts.clone();
1467    if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1468        run_opts.repack.incremental = true;
1469        run_opts.repack.write_bitmap = false;
1470    }
1471    let stats = gc_locked(repo, &run_opts, now)?;
1472    Ok(AutoGcOutcome::Ran {
1473        reason,
1474        stats: Box::new(stats),
1475    })
1476}
1477
1478/// Process recorded in `gc.pid` by a maintenance run that is still in progress.
1479#[derive(Debug, Clone, PartialEq, Eq)]
1480pub struct GcLockHolder {
1481    /// Process id from `gc.pid`, when it could be read.
1482    pub pid: Option<u64>,
1483    /// Host name from `gc.pid`, when it could be read.
1484    pub host: Option<String>,
1485}
1486
1487/// Outcome of [`GcLock::try_acquire`].
1488#[derive(Debug)]
1489#[must_use]
1490pub enum GcLockAttempt {
1491    /// This process now owns `gc.pid`.
1492    Acquired(GcLock),
1493    /// Another live maintenance run owns it.
1494    Held(GcLockHolder),
1495}
1496
1497/// How long a `gc.pid` written by another run is honored before it is treated as stale.
1498const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1499
1500/// Repository-wide maintenance lock, compatible with Git's `gc.pid`.
1501///
1502/// The lock file holds `"<pid> <hostname>"` and is removed when this value is dropped. An
1503/// existing `gc.pid` counts as held while it is younger than 12 hours and either names another
1504/// host or a process on this host that is still running; older or dead entries are taken over.
1505#[derive(Debug)]
1506pub struct GcLock {
1507    path: PathBuf,
1508}
1509
1510impl GcLock {
1511    /// Try to take the `gc.pid` lock in the repository's common directory without waiting.
1512    ///
1513    /// `now_unix` is the wall clock used to decide whether an existing `gc.pid` is stale.
1514    ///
1515    /// # Errors
1516    ///
1517    /// I/O failures while creating, writing or renaming the lock file.
1518    pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1519        let path = repo.common_git_dir().join("gc.pid");
1520        let mut lock = match crate::lockfile::LockFile::acquire(
1521            &path.with_extension("pid.lock"),
1522            crate::lockfile::LockWait::Immediately,
1523        ) {
1524            Ok(lock) => lock,
1525            Err(crate::lockfile::LockError::Held { .. }) => {
1526                return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1527                    GcLockHolder {
1528                        pid: None,
1529                        host: None,
1530                    },
1531                )));
1532            }
1533            Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1534        };
1535        let host = local_host_name();
1536        if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1537            lock.rollback();
1538            return Ok(GcLockAttempt::Held(holder));
1539        }
1540        use std::io::Write as _;
1541        write!(lock, "{} {host}", std::process::id())?;
1542        lock.commit_to(&path).map_err(|e| match e {
1543            crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1544            held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1545        })?;
1546        Ok(GcLockAttempt::Acquired(GcLock { path }))
1547    }
1548
1549    /// The maintenance run currently holding `gc.pid`, without taking the lock.
1550    ///
1551    /// Uses the same staleness rules as [`Self::try_acquire`], so `None` means an attempt would
1552    /// most likely succeed. Lets a caller avoid starting a process that would only find the
1553    /// lock held.
1554    #[must_use]
1555    pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1556        let path = repo.common_git_dir().join("gc.pid");
1557        live_gc_pid_holder(&path, &local_host_name(), now_unix)
1558    }
1559}
1560
1561impl Drop for GcLock {
1562    fn drop(&mut self) {
1563        let _ = fs::remove_file(&self.path);
1564    }
1565}
1566
1567fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1568    let raw = fs::read_to_string(path).ok()?;
1569    let mut fields = raw.split_whitespace();
1570    let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1571    let host = fields.next().map(str::to_owned);
1572    Some(GcLockHolder { pid, host })
1573}
1574
1575/// The holder recorded in `gc.pid` when it still blocks a new run on `host`.
1576fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1577    let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1578    let mtime = OffsetDateTime::from(mtime);
1579    let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1580    if now - mtime > GC_PID_STALE_AFTER {
1581        return None;
1582    }
1583    let holder = read_gc_pid(path)?;
1584    let pid = holder.pid?;
1585    let same_host = holder.host.as_deref() == Some(host);
1586    if same_host && !crate::lockfile::is_process_running(pid) {
1587        return None;
1588    }
1589    Some(holder)
1590}
1591
1592/// Host name as Git records it in `gc.pid` (`gethostname`), so either tool can tell whether
1593/// the other's lock belongs to this machine.
1594fn local_host_name() -> String {
1595    #[cfg(unix)]
1596    {
1597        if let Ok(uts) = nix::sys::utsname::uname() {
1598            if let Some(name) = uts.nodename().to_str() {
1599                return name.to_owned();
1600            }
1601        }
1602    }
1603    #[cfg(windows)]
1604    {
1605        if let Some(name) = windows_dns_host_name() {
1606            return name;
1607        }
1608    }
1609    "localhost".to_owned()
1610}
1611
1612/// The DNS host name, which is what Winsock `gethostname` (used by Git for Windows) returns.
1613#[cfg(windows)]
1614fn windows_dns_host_name() -> Option<String> {
1615    use windows_sys::Win32::System::SystemInformation::{
1616        ComputerNameDnsHostname, GetComputerNameExW,
1617    };
1618    let mut len: u32 = 0;
1619    // SAFETY: a null buffer with zero length only asks for the required size in `len`.
1620    unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1621    if len == 0 {
1622        return None;
1623    }
1624    let mut buf = vec![0u16; len as usize];
1625    // SAFETY: `buf` has room for the `len` UTF-16 units the previous call asked for.
1626    let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1627    (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1628}
1629
1630fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
1631    let mut stats = GcStats::default();
1632    let cfg = repo.config()?;
1633    let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1634    let dry_run = opts.repack.dry_run;
1635
1636    if opts.pack_refs && !dry_run {
1637        let start = Instant::now();
1638        stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1639        stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1640    }
1641
1642    if opts.expire_reflog {
1643        let start = Instant::now();
1644        stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
1645        stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
1646    }
1647
1648    let mut repack_opts = opts.repack.clone();
1649    apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
1650    if repack_opts.now.is_none() {
1651        repack_opts.now =
1652            Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
1653    }
1654    // Incremental runs skip bitmaps, as Git's `gc --auto` does: a bitmap covers the whole
1655    // object graph and costs far more than packing the loose objects.
1656    if !repack_opts.write_bitmap && !repack_opts.incremental {
1657        repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg);
1658    }
1659    let repack_start = Instant::now();
1660    stats.repack = repack_all(repo, &repack_opts)?;
1661    let repack_total_ms = duration_ms(repack_start.elapsed());
1662    stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
1663    stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
1664
1665    if dry_run {
1666        return Ok(stats);
1667    }
1668
1669    // Repack prunes unreachable loose objects itself (sharing its reachability walk), so gc
1670    // only reports the outcome.
1671    stats.prune_ran = stats.repack.unreachable_prune_ran;
1672    stats.prune = PruneStats {
1673        pruned: stats.repack.unreachable_loose_pruned,
1674        kept: stats.repack.unreachable_loose_kept,
1675    };
1676
1677    if opts.write_commit_graph {
1678        let start = Instant::now();
1679        stats.commit_graph = Some(write_commit_graph(
1680            repo,
1681            &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
1682        )?);
1683        stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
1684    }
1685    Ok(stats)
1686}
1687
1688/// How [`gc`] applies loose-object pruning for `gc.pruneExpire`.
1689enum GcLoosePrunePolicy {
1690    /// `gc.pruneExpire=never` — do not delete unreachable loose objects.
1691    Disabled,
1692    /// Prune unreachable loose objects not protected by the grace cutoff.
1693    Enabled {
1694        /// When `Some(t)`, only delete loose objects strictly older than `t` (Git grace window).
1695        keep_newer_than: Option<SystemTime>,
1696    },
1697}
1698
1699fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
1700    let spec = config
1701        .get("gc.pruneExpire")
1702        .map(|s| s.trim().to_owned())
1703        .filter(|s| !s.is_empty())
1704        .unwrap_or_else(|| "2.weeks.ago".to_owned());
1705    if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
1706        return Ok(GcLoosePrunePolicy::Disabled);
1707    }
1708    // Git expires everything for `now` and `all`, including objects written this second.
1709    if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
1710        return Ok(GcLoosePrunePolicy::Enabled {
1711            keep_newer_than: None,
1712        });
1713    }
1714    let mut err = 0;
1715    let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
1716    if err != 0 {
1717        return Err(Error::Config(ConfigError::Other(format!(
1718            "bad config value '{spec}' for 'gc.pruneExpire'"
1719        ))));
1720    }
1721    let keep_newer_than = SystemTime::UNIX_EPOCH
1722        .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
1723    Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
1724}
1725
1726fn gc_now_unix(now_unix: i64) -> i64 {
1727    if now_unix != 0 {
1728        return now_unix;
1729    }
1730    time::OffsetDateTime::now_utc().unix_timestamp()
1731}
1732
1733fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
1734    let cfg = repo.config()?;
1735    let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
1736    let params = ReflogExpireParams {
1737        stale_fix: false,
1738        dry_run,
1739        verbose: false,
1740    };
1741    let mut total = 0usize;
1742    let promises = std::cell::OnceCell::new();
1743    for refname in reflog_refs_on_store(repo.refs())? {
1744        let result = expire_reflog_git_with_promises(
1745            repo,
1746            &refname,
1747            &params,
1748            None,
1749            None,
1750            &gc_cfg.patterns,
1751            gc_cfg.global_total,
1752            gc_cfg.global_unreachable,
1753            now,
1754            &promises,
1755        )?;
1756        total += result.pruned;
1757    }
1758    Ok(total)
1759}
1760
1761fn count_loose_objects(odb: &Odb) -> Result<usize> {
1762    Ok(odb.enumerate_local_loose_objects()?.len())
1763}
1764
1765/// Build the all-into-one repack pack for `roots`, stopping history at the `shallow`
1766/// boundary commits.
1767#[allow(clippy::too_many_arguments)]
1768fn build_repack_pack(
1769    odb: &Odb,
1770    pack_dir: &Path,
1771    roots: &[ObjectId],
1772    shallow: &HashSet<ObjectId>,
1773    keep_unreachable: bool,
1774    opts: &PackBuildOptions,
1775    threads: Option<usize>,
1776    cfg: Option<&ConfigSet>,
1777    promised: &HashSet<ObjectId>,
1778) -> Result<SerializedDeltaPack> {
1779    let selection = repack_object_list(
1780        odb,
1781        roots,
1782        shallow,
1783        keep_unreachable,
1784        opts.use_bitmaps,
1785        promised,
1786    )?;
1787    write_staged_repack_pack(
1788        odb,
1789        pack_dir,
1790        &selection.send,
1791        &selection.name_hashes,
1792        opts,
1793        threads,
1794        cfg,
1795    )
1796}
1797
1798/// Stream a local pack holding exactly `objects` to a `tmp_repack_*` file in `pack_dir`,
1799/// with the delta search tuned by `opts` (`pack.window`, `pack.depth`) and
1800/// `pack.windowMemory` from `cfg`.
1801///
1802/// Every delta base is one of `objects`, so the pack is self-contained. `name_hashes`
1803/// pairs same-path blobs and trees in the search window. An empty `objects` writes
1804/// nothing and returns an empty pack.
1805///
1806/// # Errors
1807///
1808/// Propagates thread-option, object read, and staging-file I/O failures; the staging
1809/// file is removed when writing fails.
1810fn write_staged_repack_pack(
1811    odb: &Odb,
1812    pack_dir: &Path,
1813    objects: &[ObjectId],
1814    name_hashes: &HashMap<ObjectId, u32>,
1815    opts: &PackBuildOptions,
1816    threads: Option<usize>,
1817    cfg: Option<&ConfigSet>,
1818) -> Result<SerializedDeltaPack> {
1819    if objects.is_empty() {
1820        return Ok(SerializedDeltaPack {
1821            bytes: Vec::new(),
1822            index_rows: Vec::new(),
1823            staging_path: None,
1824        });
1825    }
1826    odb.clear_delta_base_cache();
1827    let max_depth = if opts.delta { opts.max_depth } else { 0 };
1828    let settings = RepackPackSettings {
1829        window: opts.window,
1830        max_depth,
1831        reuse_deltas: opts.reuse_deltas,
1832        reuse_objects: opts.reuse_objects,
1833        use_ofs_delta: opts.use_ofs_delta,
1834        window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
1835        search_threads: pack_delta_search_parallel_options(odb, threads)?
1836            .threads
1837            .get(),
1838        write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
1839    };
1840    let git_dir = odb
1841        .config_git_dir()
1842        .map(Path::to_path_buf)
1843        .unwrap_or_else(|| {
1844            pack_dir
1845                .parent()
1846                .and_then(|objects| objects.parent())
1847                .map(Path::to_path_buf)
1848                .unwrap_or_else(|| pack_dir.to_path_buf())
1849        });
1850    crate::git_dir::io_error::io_with_git_path(
1851        &git_dir,
1852        pack_dir,
1853        crate::git_dir::GitDirPathOperation::GcPack,
1854        fs::create_dir_all(pack_dir),
1855    )?;
1856    let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
1857    let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
1858    match written {
1859        Ok(index_rows) => Ok(SerializedDeltaPack {
1860            bytes: Vec::new(),
1861            index_rows,
1862            staging_path: Some(staging),
1863        }),
1864        Err(err) => {
1865            let _ = fs::remove_file(&staging);
1866            Err(err)
1867        }
1868    }
1869}
1870
1871/// Objects a full repack writes: everything reachable from `roots` down to the
1872/// `shallow` boundary commits, plus every other packed and loose object when
1873/// `keep_unreachable` is set.
1874fn repack_object_list(
1875    odb: &Odb,
1876    roots: &[ObjectId],
1877    shallow: &HashSet<ObjectId>,
1878    keep_unreachable: bool,
1879    use_bitmaps: bool,
1880    promised: &HashSet<ObjectId>,
1881) -> Result<PackObjectSelection> {
1882    // Roots arrive in hash-set order; sorting them makes the pack (and its name)
1883    // the same on every run.
1884    let mut roots: Vec<_> = roots
1885        .iter()
1886        .copied()
1887        .filter(|oid| !promised.contains(oid))
1888        .collect();
1889    roots.sort_unstable();
1890    let mut selection = enumerate_pack_objects_naming(
1891        odb,
1892        &roots,
1893        &[],
1894        &PackEnumerateOptions {
1895            filter: None,
1896            shallow_grafts: shallow,
1897            have_shallow_grafts: Some(shallow),
1898            use_bitmaps: use_bitmaps && promised.is_empty(),
1899            exclude_objects: Some(promised),
1900            peer_odb: None,
1901            have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
1902        },
1903        NameTrees::Yes,
1904    )?;
1905    if keep_unreachable {
1906        append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
1907    }
1908    Ok(selection)
1909}
1910
1911/// Append packed and loose objects not already selected for the repack send list.
1912fn append_repack_unreachable_retention(
1913    odb: &Odb,
1914    send: &mut Vec<ObjectId>,
1915    promised: &HashSet<ObjectId>,
1916) -> Result<()> {
1917    let mut in_send: OidHashSet = send.iter().copied().collect();
1918    odb.primary()?
1919        .packed_objects()
1920        .for_each_removable_pack_object(&mut |oid| {
1921            if !promised.contains(oid) && in_send.insert(*oid) {
1922                send.push(*oid);
1923            }
1924            std::ops::ControlFlow::Continue(())
1925        })?;
1926    for (oid, _) in odb.enumerate_local_loose_objects()? {
1927        if !promised.contains(&oid) && in_send.insert(oid) {
1928            send.push(oid);
1929        }
1930    }
1931    Ok(())
1932}
1933
1934/// Packs in `pack_dir` without a `.keep` marker, from a single directory read.
1935fn count_pack_files(pack_dir: &Path) -> usize {
1936    let Ok(entries) = fs::read_dir(pack_dir) else {
1937        return 0;
1938    };
1939    let mut packs = HashSet::new();
1940    let mut kept = HashSet::new();
1941    for entry in entries.filter_map(|e| e.ok()) {
1942        let path = PathBuf::from(entry.file_name());
1943        let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
1944            continue;
1945        };
1946        if ext == "pack" {
1947            packs.insert(stem.to_owned());
1948        } else if ext == "keep" {
1949            kept.insert(stem.to_owned());
1950        }
1951    }
1952    packs.difference(&kept).count()
1953}
1954
1955/// Promises may be stored outside marked packs. Keep their source ordinary packs
1956/// when no retained promisor pack supplies a copy, so repacking cannot turn an
1957/// available promise into a missing one. Only indexes are read; no object fetches.
1958fn packs_holding_available_promises(
1959    odb: &Odb,
1960    promised: &HashSet<ObjectId>,
1961) -> Result<HashSet<PathBuf>> {
1962    if promised.is_empty() {
1963        return Ok(HashSet::new());
1964    }
1965    let indexes = odb.local_pack_indexes()?;
1966    let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
1967    let mut retained = HashSet::new();
1968    for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
1969        for entry in idx.iter() {
1970            let oid = ObjectId::from_bytes(entry.oid())?;
1971            if promised.contains(&oid)
1972                && !promisor_indexes
1973                    .iter()
1974                    .any(|idx| idx.find_position(&oid).is_some())
1975            {
1976                retained.insert(idx.pack_path.clone());
1977                break;
1978            }
1979        }
1980    }
1981    Ok(retained)
1982}
1983
1984/// Remove pack artifacts in `pack_dir` except `keep_pack`, `.keep` packs, promisor packs, and
1985/// `retained_packs` (including every sidecar of those retained packs).
1986fn remove_superseded_pack_files_retaining(
1987    pack_dir: &Path,
1988    keep_pack: &Path,
1989    retained_packs: &HashSet<PathBuf>,
1990) -> Result<usize> {
1991    let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
1992    let keep_stem = keep_pack
1993        .file_stem()
1994        .and_then(|s| s.to_str())
1995        .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
1996    if !pack_dir.is_dir() {
1997        return Ok(0);
1998    }
1999    let mut removed = 0usize;
2000    for entry in crate::git_dir::io_error::io_with_git_path(
2001        &git_dir,
2002        pack_dir,
2003        crate::git_dir::GitDirPathOperation::GcPack,
2004        fs::read_dir(pack_dir),
2005    )? {
2006        let entry = entry.map_err(|source| {
2007            crate::git_dir::io_error::map_git_dir_io(
2008                &git_dir,
2009                pack_dir,
2010                crate::git_dir::GitDirPathOperation::GcPack,
2011                source,
2012            )
2013        })?;
2014        let name = entry.file_name();
2015        let name_str = name.to_string_lossy();
2016        if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2017            continue;
2018        }
2019        if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2020            continue;
2021        }
2022        let path = entry.path();
2023        if path.is_file() {
2024            if pack_has_keep_marker(pack_dir, &path)
2025                || path.with_extension("promisor").is_file()
2026                || retained_packs.contains(&path.with_extension("pack"))
2027            {
2028                continue;
2029            }
2030            crate::git_dir::io_error::io_with_git_path(
2031                &git_dir,
2032                &path,
2033                crate::git_dir::GitDirPathOperation::GcPack,
2034                fs::remove_file(&path),
2035            )?;
2036            removed += 1;
2037        }
2038    }
2039    Ok(removed)
2040}
2041
2042fn pack_has_keep_marker(pack_dir: &Path, path: &Path) -> bool {
2043    let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
2044        return false;
2045    };
2046    pack_dir.join(format!("{stem}.keep")).is_file()
2047}
2048
2049/// Pack loose refs into `packed-refs` (files backend) or compact reftable stacks.
2050///
2051/// # Errors
2052///
2053/// Propagates ref-store, locking, and I/O failures.
2054pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2055    let (path, refs_packed, refs_pruned) =
2056        crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2057    Ok(PackRefsReport {
2058        path,
2059        refs_packed,
2060        refs_pruned,
2061    })
2062}
2063
2064impl Repository {
2065    /// Write a reachable commit-graph file for this repository.
2066    ///
2067    /// # Errors
2068    ///
2069    /// See [`write_commit_graph`].
2070    pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2071        write_commit_graph(self, opts)
2072    }
2073
2074    /// Write or refresh the multi-pack index for this repository's pack directory.
2075    ///
2076    /// # Errors
2077    ///
2078    /// See [`write_midx`].
2079    pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2080        write_midx(self, opts)
2081    }
2082}
2083
2084/// Standalone object fsck helpers.
2085pub mod fsck {
2086    pub use crate::fsck_msg_policy::{
2087        FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2088    };
2089    pub use crate::fsck_standalone::*;
2090}
2091
2092/// Repository-wide fsck (object store, connectivity, refs).
2093pub mod repository_fsck {
2094    pub use crate::fsck::*;
2095}
2096
2097#[cfg(test)]
2098mod tests {
2099    #![allow(clippy::unwrap_used, clippy::expect_used)]
2100
2101    use super::*;
2102    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2103    use crate::porcelain::add::stage_for_commit;
2104    use crate::porcelain::commit::{create_commit, CommitRequest};
2105    use crate::progress::{NeverCancel, NullProgress};
2106    use crate::repo::InitOptions;
2107    use tempfile::TempDir;
2108
2109    #[cfg_attr(
2110        not(feature = "full-tests"),
2111        ignore = "starts system git or is heavy; run with --features full-tests"
2112    )]
2113    #[test]
2114    fn gc_auto_skip_single_pack_without_loose() {
2115        let dir = TempDir::new().unwrap();
2116        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2117        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2118        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2119        let ident = "Test <t@example.com> 1 +0000".to_string();
2120        create_commit(
2121            &repo,
2122            &CommitRequest {
2123                message: "init".into(),
2124                author: Some(ident.parse().expect("signature")),
2125                committer: Some(ident.parse().expect("signature")),
2126                allow_empty: false,
2127                sign_override: None,
2128                amend: false,
2129                assume_worktree_synced: false,
2130                skip_pre_commit_tree_match: false,
2131                staged_change_count: None,
2132                ..Default::default()
2133            },
2134            &mut NullProgress,
2135            &NeverCancel,
2136        )
2137        .expect("commit");
2138        git_cmd(dir.path(), &["repack", "-a", "-d"]);
2139        assert!(should_auto_skip_repack(0, 1, false, true, None));
2140        assert!(!should_auto_skip_repack(3, 1, false, true, None));
2141        assert!(!should_auto_skip_repack(0, 2, false, true, None));
2142        assert!(!should_auto_skip_repack(0, 2, false, false, None));
2143    }
2144
2145    #[test]
2146    fn count_pack_files_ignores_keep_markers() {
2147        let dir = TempDir::new().unwrap();
2148        let pack_dir = dir.path().join("pack");
2149        fs::create_dir_all(&pack_dir).unwrap();
2150        fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2151        fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2152        fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
2153        assert_eq!(count_pack_files(&pack_dir), 1);
2154    }
2155
2156    fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
2157        let dir = TempDir::new().unwrap();
2158        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2159        let config_path = repo.git_dir().join("config");
2160        let mut config = fs::read_to_string(&config_path).unwrap();
2161        config.push_str(snippet);
2162        fs::write(&config_path, config).unwrap();
2163        repo.reload_config().expect("reload config");
2164        (dir, repo)
2165    }
2166
2167    fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
2168        let dir = repo.git_dir().join("objects").join(fanout);
2169        fs::create_dir_all(&dir).unwrap();
2170        for name in names {
2171            fs::write(dir.join(name), b"").unwrap();
2172        }
2173    }
2174
2175    const NAME_A: &str = "00000000000000000000000000000000000001";
2176    const NAME_B: &str = "00000000000000000000000000000000000002";
2177    const NAME_C: &str = "00000000000000000000000000000000000003";
2178
2179    #[test]
2180    fn auto_gc_needed_samples_one_fanout_directory() {
2181        // gc.auto = 300 allows ceil(300 / 256) = 2 sampled objects.
2182        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
2183        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2184        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2185        fake_loose(&repo, "18", &[NAME_C]);
2186        fake_loose(
2187            &repo,
2188            "17",
2189            &["tmp_obj_123", "0000000000000000000000000000000000000z"],
2190        );
2191        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2192        fake_loose(&repo, "17", &[NAME_C]);
2193        assert_eq!(
2194            auto_gc_needed(&repo).unwrap(),
2195            Some(AutoGcReason::TooManyLoose {
2196                estimate: 3 * 256,
2197                limit: 300
2198            })
2199        );
2200    }
2201
2202    #[test]
2203    fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
2204        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
2205        fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
2206        let pack_dir = repo.git_dir().join("objects/pack");
2207        fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2208        fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2209        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2210        assert_eq!(
2211            gc_auto(&repo, &GcOptions::default()).unwrap(),
2212            AutoGcOutcome::NotNeeded
2213        );
2214    }
2215
2216    #[test]
2217    fn auto_gc_needed_counts_packs_without_keep_markers() {
2218        let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
2219        let pack_dir = repo.git_dir().join("objects/pack");
2220        for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
2221            fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
2222        }
2223        assert_eq!(
2224            auto_gc_needed(&repo).unwrap(),
2225            Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
2226        );
2227        fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
2228        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
2229    }
2230
2231    fn config_snippet(text: &str) -> ConfigSet {
2232        let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
2233        let mut set = ConfigSet::new();
2234        set.merge(&file);
2235        set
2236    }
2237
2238    #[test]
2239    fn prune_expire_now_and_all_have_no_grace_window() {
2240        let now = 1_700_000_000;
2241        for spec in ["now", "all", "NOW"] {
2242            let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2243            assert!(matches!(
2244                gc_loose_prune_policy(&cfg, now).unwrap(),
2245                GcLoosePrunePolicy::Enabled {
2246                    keep_newer_than: None
2247                }
2248            ));
2249        }
2250        for spec in ["never", "false"] {
2251            let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
2252            assert!(matches!(
2253                gc_loose_prune_policy(&cfg, now).unwrap(),
2254                GcLoosePrunePolicy::Disabled
2255            ));
2256        }
2257        let cfg = ConfigSet::new();
2258        assert!(matches!(
2259            gc_loose_prune_policy(&cfg, now).unwrap(),
2260            GcLoosePrunePolicy::Enabled {
2261                keep_newer_than: Some(_)
2262            }
2263        ));
2264    }
2265
2266    #[test]
2267    fn gc_lock_is_exclusive_and_released_on_drop() {
2268        let (_dir, repo) = repo_with_config("");
2269        let now = OffsetDateTime::now_utc().unix_timestamp();
2270        let pid_path = repo.git_dir().join("gc.pid");
2271        let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
2272            panic!("first acquire must succeed");
2273        };
2274        let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
2275        assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
2276        assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
2277        match GcLock::try_acquire(&repo, now).unwrap() {
2278            GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
2279            GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
2280        }
2281        assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
2282        assert!(matches!(
2283            gc(&repo, &GcOptions::default()),
2284            Err(Error::MaintenanceRunning { .. })
2285        ));
2286        assert!(!repo.git_dir().join("gc.pid.lock").exists());
2287        drop(lock);
2288        assert!(!pid_path.exists());
2289        assert_eq!(GcLock::current_holder(&repo, now), None);
2290        assert!(matches!(
2291            GcLock::try_acquire(&repo, now).unwrap(),
2292            GcLockAttempt::Acquired(_)
2293        ));
2294    }
2295
2296    #[test]
2297    fn gc_lock_takes_over_stale_entries() {
2298        let (_dir, repo) = repo_with_config("");
2299        let now = OffsetDateTime::now_utc().unix_timestamp();
2300        let pid_path = repo.git_dir().join("gc.pid");
2301        let host = local_host_name();
2302
2303        // A process on this host that is no longer running.
2304        fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
2305        assert!(matches!(
2306            GcLock::try_acquire(&repo, now).unwrap(),
2307            GcLockAttempt::Acquired(_)
2308        ));
2309
2310        // Another host cannot be checked, so a recent entry blocks.
2311        fs::write(&pid_path, "4242 some-other-host").unwrap();
2312        assert!(matches!(
2313            GcLock::try_acquire(&repo, now).unwrap(),
2314            GcLockAttempt::Held(GcLockHolder {
2315                pid: Some(4242),
2316                ..
2317            })
2318        ));
2319
2320        // The same entry is ignored once it is older than 12 hours.
2321        let later = now + time::Duration::hours(13).whole_seconds();
2322        assert!(matches!(
2323            GcLock::try_acquire(&repo, later).unwrap(),
2324            GcLockAttempt::Acquired(_)
2325        ));
2326    }
2327
2328    #[test]
2329    fn gc_auto_reports_held_lock_without_running() {
2330        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
2331        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2332        fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
2333        let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
2334        assert!(
2335            matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
2336            "{outcome:?}"
2337        );
2338        assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
2339    }
2340
2341    #[cfg(unix)]
2342    #[test]
2343    fn gc_auto_honors_failing_pre_auto_gc_hook() {
2344        use std::os::unix::fs::PermissionsExt as _;
2345        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
2346        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
2347        let hook = repo.git_dir().join("hooks/pre-auto-gc");
2348        fs::create_dir_all(hook.parent().unwrap()).unwrap();
2349        fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
2350        fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
2351        let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
2352        assert!(
2353            matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
2354            "{outcome:?}"
2355        );
2356        assert!(!repo.git_dir().join("gc.pid").exists());
2357    }
2358
2359    fn git_cmd(dir: &Path, args: &[&str]) {
2360        let status = std::process::Command::new("git")
2361            .current_dir(dir)
2362            .args(args)
2363            .env("GIT_CONFIG_GLOBAL", "/dev/null")
2364            .env("GIT_CONFIG_SYSTEM", "/dev/null")
2365            .status()
2366            .expect("git");
2367        assert!(status.success(), "git {args:?}");
2368    }
2369
2370    fn assert_fsck_clean(repo: &Repository) {
2371        let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
2372        let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
2373        assert!(
2374            report.ok(&repo.config().expect("config"), &opts),
2375            "{:?}",
2376            report.issues
2377        );
2378    }
2379
2380    #[test]
2381    fn fsck_clean_repo_ok() {
2382        let dir = TempDir::new().unwrap();
2383        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2384        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2385        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2386        let ident = "Test <t@example.com> 1 +0000".to_string();
2387        create_commit(
2388            &repo,
2389            &CommitRequest {
2390                message: "init".into(),
2391                author: Some(ident.parse().expect("signature")),
2392                committer: Some(ident.parse().expect("signature")),
2393                allow_empty: false,
2394                sign_override: None,
2395                amend: false,
2396                assume_worktree_synced: false,
2397                skip_pre_commit_tree_match: false,
2398                staged_change_count: None,
2399                ..Default::default()
2400            },
2401            &mut NullProgress,
2402            &NeverCancel,
2403        )
2404        .expect("commit");
2405        assert_fsck_clean(&repo);
2406    }
2407
2408    #[test]
2409    fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
2410        let dir = TempDir::new().unwrap();
2411        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2412        let wt = repo.work_tree().unwrap();
2413        std::fs::write(wt.join("base"), b"v1").unwrap();
2414        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2415        let ident = "Test <t@example.com> 1 +0000".to_string();
2416        create_commit(
2417            &repo,
2418            &CommitRequest {
2419                message: "base".into(),
2420                author: Some(ident.parse().expect("signature")),
2421                committer: Some(ident.parse().expect("signature")),
2422                allow_empty: false,
2423                sign_override: None,
2424                amend: false,
2425                assume_worktree_synced: false,
2426                skip_pre_commit_tree_match: false,
2427                staged_change_count: None,
2428                ..Default::default()
2429            },
2430            &mut NullProgress,
2431            &NeverCancel,
2432        )
2433        .expect("commit");
2434        repack_all(
2435            &repo,
2436            &RepackOptions {
2437                keep_unreachable: false,
2438                ..RepackOptions::default()
2439            },
2440        )
2441        .expect("repack base");
2442        let pack_dir = repo.git_dir().join("objects/pack");
2443        assert_eq!(count_pack_files(&pack_dir), 1);
2444
2445        std::fs::write(wt.join("child"), b"v2").unwrap();
2446        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
2447        create_commit(
2448            &repo,
2449            &CommitRequest {
2450                message: "child".into(),
2451                author: Some(ident.parse().expect("signature")),
2452                committer: Some(ident.parse().expect("signature")),
2453                allow_empty: false,
2454                sign_override: None,
2455                amend: false,
2456                assume_worktree_synced: false,
2457                skip_pre_commit_tree_match: false,
2458                staged_change_count: None,
2459                ..Default::default()
2460            },
2461            &mut NullProgress,
2462            &NeverCancel,
2463        )
2464        .expect("child commit");
2465        assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
2466
2467        let packs_before = count_pack_files(&pack_dir);
2468        repack_all(
2469            &repo,
2470            &RepackOptions {
2471                incremental: true,
2472                write_bitmap: true,
2473                ..RepackOptions::default()
2474            },
2475        )
2476        .expect("incremental repack with bitmap must not fail after installing a pack");
2477
2478        assert!(
2479            count_pack_files(&pack_dir) > packs_before,
2480            "incremental repack should add a pack"
2481        );
2482        let midx = pack_dir.join("multi-pack-index");
2483        assert!(
2484            midx.is_file(),
2485            "expected multi-pack-index after bitmap repack"
2486        );
2487        let midx_bytes = fs::read(&midx).expect("read midx");
2488        assert_eq!(
2489            midx_bytes.get(4),
2490            Some(&1),
2491            "MIDX on-disk version must be v1 for system git compatibility"
2492        );
2493        let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
2494            .unwrap()
2495            .filter_map(|e| e.ok())
2496            .map(|e| e.path())
2497            .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
2498            .collect();
2499        assert!(
2500            !bitmaps.is_empty(),
2501            "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
2502        );
2503
2504        assert_fsck_clean(&repo);
2505    }
2506
2507    #[test]
2508    fn incremental_repack_adds_pack_without_removing_base() {
2509        let dir = TempDir::new().unwrap();
2510        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2511        let wt = repo.work_tree().unwrap();
2512        std::fs::write(wt.join("base"), b"v1").unwrap();
2513        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2514        let ident = "Test <t@example.com> 1 +0000".to_string();
2515        create_commit(
2516            &repo,
2517            &CommitRequest {
2518                message: "base".into(),
2519                author: Some(ident.parse().expect("signature")),
2520                committer: Some(ident.parse().expect("signature")),
2521                allow_empty: false,
2522                sign_override: None,
2523                amend: false,
2524                assume_worktree_synced: false,
2525                skip_pre_commit_tree_match: false,
2526                staged_change_count: None,
2527                ..Default::default()
2528            },
2529            &mut NullProgress,
2530            &NeverCancel,
2531        )
2532        .expect("commit");
2533        repack_all(
2534            &repo,
2535            &RepackOptions {
2536                keep_unreachable: false,
2537                ..RepackOptions::default()
2538            },
2539        )
2540        .expect("repack");
2541        let pack_dir = repo.git_dir().join("objects/pack");
2542        let packs_before = count_pack_files(&pack_dir);
2543        assert_eq!(packs_before, 1);
2544        for i in 0..5 {
2545            create_commit(
2546                &repo,
2547                &CommitRequest {
2548                    message: format!("empty-{i}"),
2549                    author: Some(ident.parse().expect("signature")),
2550                    committer: Some(ident.parse().expect("signature")),
2551                    allow_empty: true,
2552                    sign_override: None,
2553                    amend: false,
2554                    assume_worktree_synced: false,
2555                    skip_pre_commit_tree_match: false,
2556                    staged_change_count: None,
2557                    ..Default::default()
2558                },
2559                &mut NullProgress,
2560                &NeverCancel,
2561            )
2562            .expect("empty");
2563        }
2564        assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
2565        repack_all(
2566            &repo,
2567            &RepackOptions {
2568                incremental: true,
2569                write_bitmap: false,
2570                ..RepackOptions::default()
2571            },
2572        )
2573        .expect("incremental repack");
2574        assert!(
2575            count_pack_files(&pack_dir) >= 2,
2576            "incremental repack should add a pack, not replace the base"
2577        );
2578    }
2579
2580    #[test]
2581    fn gc_full_run_records_per_phase_timings() {
2582        let dir = TempDir::new().unwrap();
2583        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2584        let wt = repo.work_tree().unwrap();
2585        for i in 0..5 {
2586            std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
2587            stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2588            let ident = "Test <t@example.com> 1 +0000".to_string();
2589            create_commit(
2590                &repo,
2591                &CommitRequest {
2592                    message: format!("c{i}"),
2593                    author: Some(ident.parse().expect("signature")),
2594                    committer: Some(ident.parse().expect("signature")),
2595                    allow_empty: false,
2596                    sign_override: None,
2597                    amend: false,
2598                    assume_worktree_synced: false,
2599                    skip_pre_commit_tree_match: false,
2600                    staged_change_count: None,
2601                    ..Default::default()
2602                },
2603                &mut NullProgress,
2604                &NeverCancel,
2605            )
2606            .expect("commit");
2607        }
2608        let stats = gc(
2609            &repo,
2610            &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
2611        )
2612        .expect("gc");
2613        let t = stats.phase_timings;
2614        assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
2615        assert!(
2616            t.repack_ms > 0,
2617            "repack phase must be timed separately from full gc"
2618        );
2619        assert!(stats.pack_refs.is_some());
2620        assert!(stats.commit_graph.is_some());
2621        assert_eq!(
2622            t.prune_ms, stats.repack.unreachable_prune_ms,
2623            "prune_ms must track unreachable loose prune inside repack, not repack_ms"
2624        );
2625        // Cached ref/config paths can finish pack-refs or commit-graph in under 1 ms (0 in
2626        // millisecond buckets). Require repack timing plus evidence the other phases ran.
2627        assert!(
2628            t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
2629            "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
2630        );
2631        assert!(stats.prune_ran, "full gc should run unreachable prune");
2632    }
2633}