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::alternate_boundary::AlternateSources;
24use crate::commit_graph_file::CommitGraphChain;
25use crate::commit_graph_write::{
26    atomic_replace_commit_graph, build_commit_graph_bytes_from_oid_maps,
27    collect_reachable_commit_infos_for_repository, remove_commit_graph_chain_dir,
28    remove_commit_graph_files, BloomWriteStats, CommitGraphCommitInfo,
29};
30use crate::config::{canonical_key, parse_bool, ConfigSet};
31use crate::diff::bloom::BloomFilterSettings;
32use crate::environment::Environment;
33use crate::error::{ConfigError, Error, Result};
34use crate::fsck as repo_fsck;
35use crate::gc::{prune_loose_unreachable_with, LazyReachable, PruneStats, S_IFDIR, S_IFMT};
36use crate::git_date::approx::approxidate_careful_at;
37use crate::index_pack::{
38    install_pack_bytes_with_progress, install_pack_path, install_pack_path_with_progress,
39    IngestPackOptions,
40};
41use crate::midx::{
42    clear_pack_midx_state, midx_version_from_config, midx_write_version,
43    write_multi_pack_index_with_options, MidxBitmapWriteOutcome, WriteMultiPackIndexOptions,
44    WriteMultiPackIndexResult,
45};
46use crate::objects::{
47    oid_hash_map, tag_object_line_oid, CommitRef, FileMode, ObjectId, ObjectKind, OidHashMap,
48    OidHashSet, TreeRefIter,
49};
50use crate::odb::Odb;
51use crate::odb::UnreachablePackRetention;
52use crate::pack_bitmap::{PackBitmapWriteError, PackBitmapWriteOptions, PackBitmapWriter};
53use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
54use crate::pack_name_hash::pack_name_hash_child;
55use crate::pack_object_select::{
56    enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
57};
58use crate::pack_objects::{PackBuildOptions, SerializedDeltaPack};
59use crate::pack_rev::write_pack_rev_sidecar_for_index_path;
60use crate::progress::{poll_cancel, Cancel, NeverCancel, NullProgress};
61use crate::prune_packed::{prune_packed_objects, PrunePackedOptions};
62use crate::reflog::{
63    expire_reflog_git_with_cache, load_gc_reflog_expire_config, reflog_refs_on_store,
64    ReflogExpireParams, ReflogExpiryCache,
65};
66use crate::repack_pack::{write_repack_pack, RepackPackSettings};
67use crate::repo::Repository;
68use crate::shallow::load_shallow_boundaries;
69use time::OffsetDateTime;
70
71mod drop_blobs;
72
73pub use crate::offload::{
74    offload_blobs, LopReadiness, OffloadConfigChange, OffloadError, OffloadOptions, OffloadPack,
75    OffloadPackRole, OffloadRepo, OffloadReport, LOP_GIT_DIR_KEY, LOP_KEEP_MESSAGE,
76};
77pub use drop_blobs::{
78    drop_large_blobs_enabled, drop_promised_blobs, DropExpiry, DropPromisedBlobsOptions,
79    DropPromisedBlobsReport, DropPromisedBlobsSkip, DroppedBlob, KeptBlobCounts,
80    DEFAULT_DROP_EXPIRE, DEFAULT_DROP_MIN_SIZE, DROP_LARGE_BLOBS_TASK,
81};
82
83/// Options for [`repack_all()`].
84#[derive(Debug, Clone)]
85#[non_exhaustive]
86pub struct RepackOptions {
87    /// Report actions without writing packs or deleting files.
88    pub dry_run: bool,
89    /// After repack, write `objects/info/commit-graph` when the ODB is files-backed.
90    pub write_commit_graph: bool,
91    /// After repack, write a `.bitmap` sidecar for the new pack (files-backed only).
92    pub write_bitmap: bool,
93    /// When set, prune unreachable loose objects not newer than this instant after repack.
94    pub prune_older_than: Option<OffsetDateTime>,
95    /// Wall clock passed to maintenance helpers that require an explicit time (e.g. bitmap writes).
96    pub now: Option<OffsetDateTime>,
97    /// When true (the default), do not delete unreachable loose objects after repack (matches `git repack -ad`).
98    pub keep_unreachable: bool,
99    /// Worker threads for pack indexing (`None` reads `pack.threads` / CPU count).
100    pub threads: Option<usize>,
101    /// Override the delta settings of a full repack; `None` uses
102    /// [`PackBuildOptions::for_local_copy`] (`pack.window` / `pack.depth` from config).
103    ///
104    /// A full repack honors `delta`, `window`, `max_depth`, `reuse_deltas`, `reuse_objects`,
105    /// `use_ofs_delta` and `use_bitmaps`. Objects already in a pack keep their stored form
106    /// when the reuse flags allow it; only the rest go through the `window` search.
107    pub pack_build: Option<PackBuildOptions>,
108    /// Config/environment for ingest and pack threads (defaults to repo config).
109    pub merged_config: Option<ConfigSet>,
110    /// Process environment for config discovery and thread counts.
111    pub environment: Environment,
112    /// Pack only the reachable loose objects into a new pack, keeping existing packs (Git
113    /// `repack -d` without `-a`). Unreachable loose objects stay loose for the prune step.
114    /// The new pack is delta-compressed like a full repack, with bases taken only from the
115    /// loose objects it holds, so it is self-contained.
116    ///
117    /// When [`Self::write_bitmap`] is true and the repository already has pack files, bitmaps are
118    /// written via a multi-pack-index sidecar instead of a per-pack `.bitmap` on the new pack alone.
119    /// Bitmaps are omitted when known promises prevent a complete closure.
120    pub incremental: bool,
121    /// When true, [`gc()`] may skip repack on a single pack with no loose objects when every
122    /// packed object is still reachable and none would be dropped by the active prune policy.
123    /// Explicit [`repack_all`] / [`repack`] leave this false so maintenance still runs.
124    pub auto_skip: bool,
125    /// When true, recompute every delta instead of keeping the ones stored in packs
126    /// (`git repack -f`, `grit repack --force`). Whole objects that stay whole are
127    /// still copied verbatim.
128    pub force_recompress: bool,
129}
130
131impl Default for RepackOptions {
132    fn default() -> Self {
133        Self {
134            dry_run: false,
135            write_commit_graph: false,
136            write_bitmap: false,
137            prune_older_than: None,
138            now: None,
139            keep_unreachable: true,
140            threads: None,
141            pack_build: None,
142            merged_config: None,
143            environment: Environment::empty(),
144            incremental: false,
145            auto_skip: false,
146            force_recompress: false,
147        }
148    }
149}
150
151impl RepackOptions {
152    /// Start from [`Default::default`] with [`Self::dry_run`] set.
153    #[must_use]
154    pub fn with_dry_run(dry_run: bool) -> Self {
155        Self {
156            dry_run,
157            ..Self::default()
158        }
159    }
160}
161
162struct RepackDetail {
163    report: RepackReport,
164    pack_path: Option<PathBuf>,
165    prune_packed_loose: usize,
166    unreachable_loose: PruneStats,
167    unreachable_prune_ms: u64,
168    unreachable_prune_ran: bool,
169}
170
171/// Counters produced by [`repack`].
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
173#[non_exhaustive]
174pub struct RepackReport {
175    /// New pack files installed (0 or 1 for all-into-one repack).
176    pub packs_written: usize,
177    /// Pack sidecar files removed from `objects/pack/` (excluding `.keep` and promisor packs).
178    pub packs_removed: usize,
179    /// Loose object files removed (`prune-packed` plus unreachable prune).
180    pub loose_removed: usize,
181    /// Objects written into the new pack.
182    pub objects: usize,
183}
184
185/// Extended counters and paths produced by [`repack_all`].
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct RepackStats {
189    /// Loose object files before repack.
190    pub loose_before: usize,
191    /// Pack files before repack.
192    pub packs_before: usize,
193    /// Objects written into the new local pack, or estimated for a dry run.
194    /// `None` when no objects need packing.
195    pub objects_packed: Option<usize>,
196    /// Bytes in the new local pack, excluding retained packs (`None` on dry run).
197    pub pack_bytes: Option<u64>,
198    /// Path to the installed local pack, even when other packs are retained.
199    /// `None` on dry run or when nothing was packed.
200    pub pack_path: Option<PathBuf>,
201    /// Sidecar/index files removed from `objects/pack/`.
202    pub removed_pack_files: usize,
203    /// Loose objects removed because they were redundant with the new pack.
204    pub pruned_loose: usize,
205    /// Unreachable loose objects removed after repack when pruning is enabled.
206    pub unreachable_loose_pruned: usize,
207    /// Unreachable loose objects kept (grace window or concurrent safety).
208    pub unreachable_loose_kept: usize,
209    /// Wall time spent in unreachable loose-object pruning during this repack.
210    pub unreachable_prune_ms: u64,
211    /// True when unreachable loose-object pruning was executed (even if nothing was removed).
212    pub unreachable_prune_ran: bool,
213}
214
215/// Options for [`Repository::write_commit_graph`].
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
217#[non_exhaustive]
218pub struct CommitGraphOptions {
219    /// When true, emit Bloom filter chunks for changed-path queries.
220    pub changed_paths: bool,
221    /// Report without writing `objects/info/commit-graph`.
222    pub dry_run: bool,
223}
224
225impl CommitGraphOptions {
226    /// Build options with an explicit changed-paths Bloom setting.
227    #[must_use]
228    pub fn with_changed_paths(changed_paths: bool) -> Self {
229        Self {
230            changed_paths,
231            ..Self::default()
232        }
233    }
234
235    /// Build options with [`Self::dry_run`] set.
236    #[must_use]
237    pub fn with_dry_run(dry_run: bool) -> Self {
238        Self {
239            dry_run,
240            ..Self::default()
241        }
242    }
243}
244
245/// Result of writing `objects/info/commit-graph`.
246#[derive(Debug, Clone, PartialEq, Eq)]
247#[non_exhaustive]
248pub struct CommitGraphWriteReport {
249    /// Path to the monolithic commit-graph file.
250    pub path: PathBuf,
251    /// Commits encoded in the graph (`0` when [`Self::skipped`] is set).
252    pub commit_count: usize,
253    /// Whether a split `commit-graphs/` chain was removed.
254    pub removed_chain: bool,
255    /// Bloom generation counters when [`CommitGraphOptions::changed_paths`] was set and a
256    /// graph was written.
257    pub bloom_stats: Option<BloomWriteStats>,
258    /// Why no graph was written, when the repository rewrites parent lists.
259    pub skipped: Option<CommitGraphSkipReason>,
260}
261
262/// Why [`write_commit_graph`] wrote no graph.
263///
264/// A commit-graph stores each commit's parents as recorded in the commit object. When the
265/// repository overrides those parents, a graph would disagree with what Git sees: Git
266/// neither writes nor reads a graph in that state, and `git fsck` rejects one that exists.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268#[non_exhaustive]
269pub enum CommitGraphSkipReason {
270    /// `.git/shallow` lists boundary commits whose parents are cut off.
271    Shallow,
272    /// `info/grafts` overrides the parents of at least one commit.
273    Grafts,
274    /// Replace refs (`refs/replace/`) are present and enabled.
275    ReplaceRefs,
276}
277
278impl CommitGraphSkipReason {
279    /// Stable kebab-case identifier: `shallow`, `grafts` or `replace-refs`.
280    #[must_use]
281    pub fn as_str(self) -> &'static str {
282        match self {
283            Self::Shallow => "shallow",
284            Self::Grafts => "grafts",
285            Self::ReplaceRefs => "replace-refs",
286        }
287    }
288
289    /// Check whether the repository at `git_dir` rewrites parent lists.
290    ///
291    /// `git_dir` may be a linked worktree's git directory; shallow and graft files are read
292    /// from the common directory. `has_replace_refs` is only called when neither applies and
293    /// reports whether enabled replace refs exist.
294    pub(crate) fn detect(
295        git_dir: &Path,
296        has_replace_refs: impl FnOnce() -> Result<bool>,
297    ) -> Result<Option<Self>> {
298        let common = crate::repo::common_git_dir_for_config(git_dir);
299        if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
300            return Ok(Some(Self::Shallow));
301        }
302        if !crate::rev_parse::load_graft_parents(&common).is_empty() {
303            return Ok(Some(Self::Grafts));
304        }
305        Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
306    }
307
308    /// Like [`Self::detect`] for an open repository, honoring `core.useReplaceRefs`,
309    /// `GIT_NO_REPLACE_OBJECTS` and `GIT_REPLACE_REF_BASE`.
310    pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
311        Self::detect(repo.git_dir(), || {
312            let Some(base) = repo.replace_ref_base_if_enabled() else {
313                return Ok(false);
314            };
315            Ok(!repo.references().list(&base)?.is_empty())
316        })
317    }
318}
319
320/// Options for [`Repository::write_midx`].
321#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
322#[non_exhaustive]
323pub struct MidxOptions {
324    /// Write a reachability `.bitmap` sidecar when the MIDX is a root file.
325    pub bitmap: bool,
326    /// Report without writing `multi-pack-index`.
327    pub dry_run: bool,
328    /// On-disk MIDX version (`1` or `2`). `None` uses `midx.version` from the repository
329    /// config, and v1 when that is unset, so every Git with multi-pack-index support can
330    /// read the result.
331    pub version: Option<u8>,
332}
333
334impl MidxOptions {
335    /// Build MIDX write options with an explicit bitmap sidecar flag.
336    #[must_use]
337    pub fn with_bitmap(bitmap: bool) -> Self {
338        Self {
339            bitmap,
340            ..Self::default()
341        }
342    }
343
344    /// Build options with [`Self::dry_run`] set.
345    #[must_use]
346    pub fn with_dry_run(dry_run: bool) -> Self {
347        Self {
348            dry_run,
349            ..Self::default()
350        }
351    }
352}
353
354/// Result of writing `objects/pack/multi-pack-index`.
355#[derive(Debug, Clone, PartialEq, Eq)]
356#[non_exhaustive]
357pub struct MidxWriteReport {
358    /// Path to the tip MIDX file.
359    pub path: PathBuf,
360    /// Pack indexes covered by the MIDX.
361    pub pack_count: usize,
362    /// Whether a reachability bitmap was written or skipped.
363    pub bitmap: MidxBitmapWriteOutcome,
364}
365
366/// Options for [`References::pack`](crate::refs::References::pack).
367#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
368#[non_exhaustive]
369pub struct PackRefsOptions {
370    /// When true, pack every packable ref (`git pack-refs --all`); otherwise only tags.
371    pub all: bool,
372    /// Report without rewriting `packed-refs` or deleting loose refs.
373    pub dry_run: bool,
374}
375
376impl PackRefsOptions {
377    /// Build pack-refs options matching `git pack-refs` / `--all`.
378    #[must_use]
379    pub fn with_all(all: bool) -> Self {
380        Self {
381            all,
382            ..Self::default()
383        }
384    }
385
386    /// Build options with [`Self::dry_run`] set.
387    #[must_use]
388    pub fn with_dry_run(dry_run: bool) -> Self {
389        Self {
390            dry_run,
391            ..Self::default()
392        }
393    }
394}
395
396/// Result of packing refs (files `packed-refs` or reftable compaction).
397#[derive(Debug, Clone, PartialEq, Eq)]
398#[non_exhaustive]
399pub struct PackRefsReport {
400    /// `packed-refs` path or the `reftable/` directory.
401    pub path: PathBuf,
402    /// Loose refs merged into the packed store.
403    pub refs_packed: usize,
404    /// Loose ref files removed (files backend) or tables merged (reftable).
405    pub refs_pruned: usize,
406}
407
408/// Options for [`gc()`].
409#[derive(Debug, Clone)]
410#[non_exhaustive]
411pub struct GcOptions {
412    /// Repack options applied before pruning unreachable objects.
413    pub repack: RepackOptions,
414    /// Expire reflogs using `gc.reflogExpire*` before repack/prune.
415    pub expire_reflog: bool,
416    /// Unix seconds for reflog expiry rules.
417    pub now_unix: i64,
418    /// When true, pack loose refs before other gc steps.
419    pub pack_refs: bool,
420    /// When true, write `objects/info/commit-graph` after repack/prune.
421    ///
422    /// Changed-path Bloom filters are kept when the existing graph has them and are not
423    /// added otherwise, matching `git gc`.
424    pub write_commit_graph: bool,
425    /// When true (the default), run the drop-large-blobs phase after repack if
426    /// `maintenance.drop-large-blobs.enabled` is set (see [`drop_promised_blobs`]).
427    /// False never runs it. The phase takes its recency cutoff from
428    /// [`Self::now_unix`] and is skipped ([`DropPromisedBlobsSkip::NoCurrentTime`])
429    /// when that is 0.
430    pub drop_large_blobs: bool,
431}
432
433impl Default for GcOptions {
434    fn default() -> Self {
435        Self {
436            repack: RepackOptions {
437                auto_skip: true,
438                ..RepackOptions::default()
439            },
440            expire_reflog: true,
441            now_unix: 0,
442            pack_refs: true,
443            write_commit_graph: true,
444            drop_large_blobs: true,
445        }
446    }
447}
448
449impl GcOptions {
450    /// Run gc with repack dry-run control and an explicit wall-clock for reflog expiry.
451    #[must_use]
452    pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
453        Self {
454            repack: RepackOptions {
455                dry_run,
456                auto_skip: true,
457                ..RepackOptions::default()
458            },
459            expire_reflog: true,
460            now_unix,
461            ..Self::default()
462        }
463    }
464
465    /// Default repack with explicit reflog expiry policy and wall-clock.
466    #[must_use]
467    pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
468        Self {
469            repack: RepackOptions {
470                auto_skip: true,
471                ..RepackOptions::default()
472            },
473            expire_reflog,
474            now_unix,
475            ..Self::default()
476        }
477    }
478
479    /// Full maintenance run: pack-refs, reflog expiry, repack, prune, commit-graph.
480    #[must_use]
481    pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
482        Self {
483            repack: RepackOptions {
484                dry_run,
485                auto_skip: true,
486                ..RepackOptions::default()
487            },
488            expire_reflog: true,
489            now_unix,
490            pack_refs: true,
491            write_commit_graph: true,
492            ..Self::default()
493        }
494    }
495}
496
497impl PruneOptions {
498    /// Prune with an optional explicit cutoff (`None` uses `gc.pruneExpire`).
499    #[must_use]
500    pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
501        Self {
502            older_than,
503            ..Self::default()
504        }
505    }
506}
507
508/// Object ids that must stay reachable during repack, prune, and fsck walks.
509///
510/// Includes ref tips, symbolic `HEAD`, every reflog `old`/`new` oid, and every
511/// blob/tree oid recorded in the index (including unmerged stages; gitlinks are excluded).
512///
513/// # Errors
514///
515/// Propagates ref, reflog, or index load failures.
516pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
517    repo_fsck::reachability_roots(repo)
518}
519
520pub(crate) fn bloom_settings_for_commit_graph_write(
521    config: &ConfigSet,
522    changed_paths: bool,
523) -> BloomFilterSettings {
524    let mut bloom = BloomFilterSettings::default();
525    if !changed_paths {
526        return bloom;
527    }
528    if let Some(v) = config
529        .get("commitgraph.changedpathsversion")
530        .and_then(|s| s.trim().parse::<i32>().ok())
531    {
532        if v == 2 {
533            bloom.hash_version = 2;
534        } else if v == 1 {
535            bloom.hash_version = 1;
536        }
537    }
538    bloom
539}
540
541/// Whether the current commit-graph carries changed-path Bloom filters.
542///
543/// Maintenance rewrites keep Bloom filters when the graph already has them and otherwise
544/// write without them, as Git's gc does: computing filters from scratch diffs every
545/// commit's tree and costs far more than the rest of the graph.
546fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
547    repo.odb()
548        .files_objects_dir()
549        .and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
550        .is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
551}
552
553pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
554    config
555        .get("commitgraph.generationversion")
556        .and_then(|s| s.trim().parse::<i32>().ok())
557        .map(|v| v >= 2)
558        .unwrap_or(true)
559}
560
561/// A commit-graph file built in memory by [`build_commit_graph`].
562#[derive(Debug, Clone, PartialEq, Eq)]
563#[non_exhaustive]
564pub struct BuiltCommitGraph {
565    /// The complete file, byte-for-byte what Git reads from `objects/info/commit-graph`.
566    pub bytes: Vec<u8>,
567    /// Commits recorded in the graph.
568    pub commit_count: usize,
569    /// Bloom generation counters when [`CommitGraphOptions::changed_paths`] was set.
570    pub bloom_stats: Option<BloomWriteStats>,
571}
572
573impl BuiltCommitGraph {
574    /// Parse [`Self::bytes`] into a chain for
575    /// [`OdbBuilder::commit_graph`](crate::odb::OdbBuilder::commit_graph).
576    ///
577    /// # Errors
578    ///
579    /// Returns [`Error::CorruptObject`] if the bytes fail verification (see
580    /// [`CommitGraphChain::from_bytes`]).
581    pub fn to_chain(&self) -> Result<CommitGraphChain> {
582        CommitGraphChain::from_bytes(self.bytes.clone())
583    }
584}
585
586/// Outcome of [`build_commit_graph`].
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub enum CommitGraphBuild {
589    /// The graph of every commit reachable from the repository's refs.
590    Built(BuiltCommitGraph),
591    /// The repository rewrites parent lists, so no graph may describe it.
592    Skipped(CommitGraphSkipReason),
593}
594
595/// Build a reachable commit-graph in memory, for any object store.
596///
597/// Reachable commits are discovered from [`Repository::refs`](crate::repo::Repository::refs)
598/// and read through [`Repository::odb`](crate::repo::Repository::odb), so this works for a
599/// custom primary ([`crate::odb::OdbBuilder::primary`]) as well as the files backend. Nothing is
600/// written: a custom store persists [`BuiltCommitGraph::bytes`] in its own storage and attaches
601/// the graph when it next opens the repository, with
602/// [`OdbBuilder::commit_graph`](crate::odb::OdbBuilder::commit_graph) and
603/// [`CommitGraphChain::from_bytes`]. For a files-backed repository [`write_commit_graph`] builds
604/// the same bytes and installs them under `objects/info/`.
605///
606/// With [`CommitGraphOptions::changed_paths`], changed-path Bloom filters are added, reusing
607/// the filters of the graph the repository's walks already use (the attached graph, or the
608/// files backend's on-disk graph) where their settings match.
609/// [`CommitGraphOptions::dry_run`] has no effect. In a shallow repository, with
610/// `info/grafts`, or with enabled replace refs, [`CommitGraphBuild::Skipped`] says why no graph
611/// was built (see [`CommitGraphSkipReason`]).
612///
613/// # Errors
614///
615/// Propagates ref, object read and config errors.
616pub fn build_commit_graph(
617    repo: &Repository,
618    opts: &CommitGraphOptions,
619) -> Result<CommitGraphBuild> {
620    if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
621        return Ok(CommitGraphBuild::Skipped(reason));
622    }
623    let odb = repo.odb();
624    let existing = odb.commit_graph_chain();
625    let commits = collect_reachable_commit_infos_for_repository(repo)?;
626    build_commit_graph_for_commits(
627        odb,
628        &commits,
629        &*repo.config()?,
630        opts.changed_paths,
631        existing.as_deref(),
632    )
633    .map(CommitGraphBuild::Built)
634}
635
636/// Encode a commit-graph of the commits in `infos` (as collected by
637/// [`collect_reachable_commit_infos_for_repository`]); `odb` serves the trees Bloom filters diff.
638///
639/// Bloom filters and generation data follow `config` (`commitGraph.changedPathsVersion`,
640/// `commitGraph.generationVersion`); with `changed_paths`, filters already in `existing` are
641/// reused where their settings match.
642pub(crate) fn build_commit_graph_for_commits(
643    odb: &Odb,
644    infos: &OidHashMap<CommitGraphCommitInfo>,
645    config: &ConfigSet,
646    changed_paths: bool,
647    existing: Option<&CommitGraphChain>,
648) -> Result<BuiltCommitGraph> {
649    let bloom = bloom_settings_for_commit_graph_write(config, changed_paths);
650    let write_generation = commit_graph_writes_generation_data(config);
651    let mut sorted: Vec<ObjectId> = infos.keys().copied().collect();
652    sorted.sort_unstable();
653
654    let mut existing_filters = oid_hash_map();
655    let mut upgraded_filters = oid_hash_map();
656    if let (true, Some(chain)) = (changed_paths, existing) {
657        for oid in &sorted {
658            if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
659                existing_filters.insert(*oid, bytes);
660            } else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
661                upgraded_filters.insert(*oid, bytes);
662            }
663        }
664    }
665
666    let (bytes, bloom_stats) = build_commit_graph_bytes_from_oid_maps(
667        &sorted,
668        infos,
669        odb,
670        changed_paths,
671        &bloom,
672        None,
673        &[],
674        None,
675        &existing_filters,
676        &upgraded_filters,
677        write_generation,
678    )?;
679    Ok(BuiltCommitGraph {
680        bytes,
681        commit_count: sorted.len(),
682        bloom_stats: changed_paths.then_some(bloom_stats),
683    })
684}
685
686/// Write a monolithic reachable commit-graph under `objects/info/commit-graph`.
687///
688/// Reachable commits are discovered from [`Repository::refs`](crate::repo::Repository::refs)
689/// (reftable, packed-refs, and linked-worktree visibility), not by scanning `git_dir/refs` alone.
690///
691/// When there are no reachable commits, nothing is written (matching `git commit-graph write`).
692/// An existing graph is left in place.
693///
694/// In a shallow repository, with `info/grafts`, or with enabled replace refs, nothing is
695/// written and [`CommitGraphWriteReport::skipped`] says why (see [`CommitGraphSkipReason`]).
696/// Git only skips the write in that state, but `git fsck` still verifies a graph that is
697/// already there, so an existing graph and split chain are deleted as well (except in
698/// [`CommitGraphOptions::dry_run`]).
699///
700/// Only a files-backed repository has an `objects/info/` directory to install the graph in.
701/// A custom primary builds the same graph with [`build_commit_graph`] and stores it itself.
702///
703/// # Errors
704///
705/// Returns [`Error::UnsupportedObjectStore`] for non-files ODB backends, or errors from graph
706/// generation and atomic I/O.
707pub fn write_commit_graph(
708    repo: &Repository,
709    opts: &CommitGraphOptions,
710) -> Result<CommitGraphWriteReport> {
711    let odb = repo.odb();
712    odb.require_files_primary("commit-graph write")?;
713    let objects_dir = odb
714        .files_objects_dir()
715        .ok_or(Error::UnsupportedObjectStore {
716            operation: "commit-graph write",
717        })?;
718    let path = objects_dir.join("info").join("commit-graph");
719
720    if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
721        let removed_chain = if opts.dry_run {
722            false
723        } else {
724            let removed = remove_commit_graph_files(objects_dir)?;
725            repo.caches().invalidate_commit_graph_cache();
726            removed
727        };
728        return Ok(CommitGraphWriteReport {
729            path,
730            commit_count: 0,
731            removed_chain,
732            bloom_stats: None,
733            skipped: Some(reason),
734        });
735    }
736
737    let commits = collect_reachable_commit_infos_for_repository(repo)?;
738    if opts.dry_run {
739        return Ok(CommitGraphWriteReport {
740            path,
741            commit_count: commits.len(),
742            removed_chain: false,
743            bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
744            skipped: None,
745        });
746    }
747    if commits.is_empty() {
748        return Ok(CommitGraphWriteReport {
749            path,
750            commit_count: 0,
751            removed_chain: false,
752            bloom_stats: None,
753            skipped: None,
754        });
755    }
756
757    let existing = CommitGraphChain::try_load(objects_dir).ok().flatten();
758    let built = build_commit_graph_for_commits(
759        odb,
760        &commits,
761        &*repo.config()?,
762        opts.changed_paths,
763        existing.as_ref(),
764    )?;
765
766    let written_path = atomic_replace_commit_graph(objects_dir, &built.bytes)?;
767    let removed_chain = remove_commit_graph_chain_dir(objects_dir);
768    repo.caches().invalidate_commit_graph_cache();
769
770    Ok(CommitGraphWriteReport {
771        path: written_path,
772        commit_count: built.commit_count,
773        removed_chain,
774        bloom_stats: built.bloom_stats,
775        skipped: None,
776    })
777}
778
779/// Write or refresh `objects/pack/multi-pack-index` for the repository.
780///
781/// The on-disk version comes from [`MidxOptions::version`], then `midx.version`, then v1.
782///
783/// # Errors
784///
785/// Returns [`Error::UnsupportedObjectStore`] for non-files backends, [`Error::Config`] when
786/// `midx.version` is not an integer, [`crate::error::MidxError::UnknownWriteVersion`] (as
787/// [`Error::Midx`]) when the version is not `1` or `2`, or MIDX write failures.
788pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
789    let odb = repo.odb();
790    odb.require_files_primary("midx write")?;
791    let requested = match opts.version {
792        Some(v) => Some(v),
793        None => midx_version_from_config(&*repo.config()?)?,
794    };
795    let version = Some(midx_write_version(requested)?);
796    let objects_dir = odb
797        .files_objects_dir()
798        .ok_or(Error::UnsupportedObjectStore {
799            operation: "midx write",
800        })?;
801    let pack_dir = objects_dir.join("pack");
802    let pack_count = fs::read_dir(&pack_dir)
803        .map(|rd| {
804            rd.filter_map(|e| e.ok())
805                .filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
806                .filter(|e| e.path().with_extension("pack").is_file())
807                .count()
808        })
809        .unwrap_or(0);
810
811    let path = pack_dir.join("multi-pack-index");
812    if opts.dry_run {
813        return Ok(MidxWriteReport {
814            path,
815            pack_count,
816            bitmap: MidxBitmapWriteOutcome::NotRequested,
817        });
818    }
819    let write_opts = WriteMultiPackIndexOptions {
820        write_bitmap: opts.bitmap,
821        version,
822        ..WriteMultiPackIndexOptions::default()
823    };
824    let WriteMultiPackIndexResult { bitmap } =
825        write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
826    Ok(MidxWriteReport {
827        path,
828        pack_count,
829        bitmap,
830    })
831}
832
833/// Options for [`prune_loose_objects`].
834#[derive(Debug, Clone, Default)]
835#[non_exhaustive]
836pub struct PruneOptions {
837    /// When set, only delete unreachable loose objects strictly older than this time.
838    ///
839    /// When `None`, uses `gc.pruneExpire` from repository config (default `2.weeks.ago`).
840    pub older_than: Option<SystemTime>,
841    /// Report without deleting objects.
842    pub dry_run: bool,
843}
844
845/// Wall-clock milliseconds for each phase of [`gc`].
846#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
847#[non_exhaustive]
848pub struct GcPhaseTimings {
849    /// [`pack_refs`] when run as part of gc.
850    pub pack_refs_ms: u64,
851    /// Reflog expiry before repack.
852    pub reflog_ms: u64,
853    /// Repack work excluding embedded unreachable prune.
854    pub repack_ms: u64,
855    /// Unreachable loose-object prune (during repack or standalone).
856    pub prune_ms: u64,
857    /// Commit-graph write after repack.
858    pub commit_graph_ms: u64,
859    /// Drop-large-blobs phase after repack.
860    pub drop_large_blobs_ms: u64,
861}
862
863/// Result of [`gc`].
864#[derive(Debug, Clone, Default, PartialEq, Eq)]
865pub struct GcStats {
866    /// Loose refs packed during gc (`None` when skipped).
867    pub pack_refs: Option<PackRefsReport>,
868    /// Counters from the repack phase.
869    pub repack: RepackStats,
870    /// Counters from loose-object pruning after repack.
871    pub prune: PruneStats,
872    /// True when full gc ran unreachable loose-object pruning during repack.
873    pub prune_ran: bool,
874    /// Monolithic commit-graph written after repack/prune (`None` when skipped).
875    pub commit_graph: Option<CommitGraphWriteReport>,
876    /// Reflog lines removed by expiry during gc.
877    pub reflog_entries_pruned: usize,
878    /// The drop-large-blobs phase (`None` when [`GcOptions::drop_large_blobs`] is
879    /// false). Reports [`DropPromisedBlobsSkip::Disabled`] unless
880    /// `maintenance.drop-large-blobs.enabled` is set.
881    pub drop_large_blobs: Option<DropPromisedBlobsReport>,
882    /// Per-phase wall times for the gc orchestration.
883    pub phase_timings: GcPhaseTimings,
884}
885
886/// Repack local objects into a single new pack and drop redundant storage.
887///
888/// Mirrors a minimal `git repack -ad`: one pack for [`reachability_roots`](crate::fsck::reachability_roots), optional `.rev` /
889/// `.bitmap` sidecars, delete other packs, [`prune_packed_objects`], and optional unreachable
890/// loose-object pruning. Existing promisor packs and all their sidecars are retained
891/// separately, as are ordinary packs holding promised objects unavailable in those
892/// promisor packs. Promised objects are excluded from the local pack; missing promised
893/// targets never trigger remote reads. Bitmaps are omitted when promises prevent a
894/// complete closure. Dry runs and incremental repacks use the same promise boundary.
895///
896/// # Errors
897///
898/// Propagates pack build/install failures, unsupported custom ODB backends, and I/O errors.
899pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
900    Ok(repack_detail(repo, opts, &NeverCancel)?.report)
901}
902
903fn repack_detail(
904    repo: &Repository,
905    opts: &RepackOptions,
906    cancel: &dyn Cancel,
907) -> Result<RepackDetail> {
908    poll_cancel(&mut NullProgress, cancel)?;
909    let odb = repo.odb();
910    odb.require_files_primary("repack")?;
911    let objects_dir = odb
912        .files_objects_dir()
913        .ok_or(Error::UnsupportedObjectStore {
914            operation: "repack",
915        })?;
916    let pack_dir = objects_dir.join("pack");
917
918    let mut report = RepackReport::default();
919    let loose_before = count_loose_objects(odb)?;
920    let packs_before = count_pack_files(&pack_dir);
921    let pack_inventory = pack_repack_inventory(&pack_dir);
922
923    #[cfg(test)]
924    gc_test_hooks::run(gc_test_hooks::GcPausePoint::AfterInventoryBeforeRoots);
925
926    let roots = collect_maintenance_object_roots(repo)?;
927
928    #[cfg(test)]
929    gc_test_hooks::run(gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll);
930    if roots.is_empty() && loose_before == 0 && packs_before == 0 {
931        return Ok(RepackDetail {
932            report,
933            pack_path: None,
934            prune_packed_loose: 0,
935            unreachable_loose: PruneStats::default(),
936            unreachable_prune_ms: 0,
937            unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
938        });
939    }
940
941    let cfg = opts
942        .merged_config
943        .clone()
944        .or_else(|| repo.config().ok().map(|c| (*c).clone()));
945    let mut pack_opts = opts
946        .pack_build
947        .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
948    if opts.force_recompress {
949        pack_opts.reuse_deltas = false;
950    }
951
952    let run_incremental = opts.incremental;
953    let shallow = load_shallow_boundaries(repo.git_dir());
954    let reachable = LazyReachable::new(odb, &roots, &shallow);
955
956    let skip_repack = !opts.force_recompress
957        && !opts.write_bitmap
958        && !opts.write_commit_graph
959        && should_auto_skip_repack(
960            loose_before,
961            packs_before,
962            run_incremental,
963            opts.auto_skip,
964            cfg.as_ref(),
965        )
966        && !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
967
968    if skip_repack {
969        // Nothing is loose, but pruning still validates the roots without walking
970        // their closure. A missing local root must not become a successful no-op.
971        reachable.require_roots_exist()?;
972        return Ok(RepackDetail {
973            report,
974            pack_path: None,
975            prune_packed_loose: 0,
976            unreachable_loose: PruneStats::default(),
977            unreachable_prune_ms: 0,
978            unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
979        });
980    }
981    let promised = reachable.promised()?;
982    let partial_clone = !promised.is_empty()
983        || cfg.as_ref().is_some_and(|config| {
984            crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config)
985        });
986    if partial_clone {
987        // Validate local reachability without giving the pack enumerator a chance to
988        // lazy-fetch genuinely missing local objects in a partial clone.
989        reachable.get()?;
990    }
991    if opts.dry_run {
992        report.objects = if run_incremental {
993            reachable_loose_objects(odb, &reachable)?.len()
994        } else {
995            if !opts.keep_unreachable {
996                reachable
997                    .get()?
998                    .iter()
999                    .filter(|oid| !promised.contains(oid))
1000                    .count()
1001            } else {
1002                repack_object_list(odb, &roots, &shallow, true, pack_opts.use_bitmaps, promised)?
1003                    .send
1004                    .len()
1005            }
1006        };
1007        return Ok(RepackDetail {
1008            report,
1009            pack_path: None,
1010            prune_packed_loose: 0,
1011            unreachable_loose: PruneStats::default(),
1012            unreachable_prune_ms: 0,
1013            unreachable_prune_ran: false,
1014        });
1015    }
1016
1017    let retained_packs = if run_incremental {
1018        HashSet::new()
1019    } else {
1020        packs_holding_available_promises(odb, promised)?
1021    };
1022    poll_cancel(&mut NullProgress, cancel)?;
1023    let built_pack = if run_incremental {
1024        let (loose, name_hashes) = named_reachable_loose_objects(odb, &reachable, &roots)?;
1025        write_staged_repack_pack(
1026            odb,
1027            &pack_dir,
1028            &loose,
1029            &name_hashes,
1030            &pack_opts,
1031            opts.threads,
1032            cfg.as_ref(),
1033        )?
1034    } else {
1035        build_repack_pack(
1036            odb,
1037            &pack_dir,
1038            &roots,
1039            &shallow,
1040            opts.keep_unreachable,
1041            &pack_opts,
1042            opts.threads,
1043            cfg.as_ref(),
1044            promised,
1045        )?
1046    };
1047    let pack_bytes_len = built_pack
1048        .staging_path
1049        .as_ref()
1050        .and_then(|p| std::fs::metadata(p).ok())
1051        .map(|m| m.len() as usize)
1052        .unwrap_or_else(|| built_pack.bytes.len());
1053    let mut prune_packed_loose = 0usize;
1054    let mut unreachable_loose = PruneStats::default();
1055    let mut installed_pack = None;
1056    let pack_trailer_len = 12 + odb.hash_algo().len();
1057    if pack_bytes_len > pack_trailer_len {
1058        poll_cancel(&mut NullProgress, cancel)?;
1059        clear_pack_midx_state(&pack_dir)?;
1060
1061        let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
1062        let ingest_opts = IngestPackOptions {
1063            fix_thin: false,
1064            threads: opts.threads,
1065            merged_config: cfg.clone(),
1066            environment: opts.environment.clone(),
1067            // Locally built pack: trailer checked at write time; index rows match the stream.
1068            skip_post_index_verify: trusted_rows.is_some(),
1069            trusted_index_rows: trusted_rows,
1070            ..IngestPackOptions::default()
1071        };
1072        let mut progress = NullProgress;
1073        let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
1074            install_pack_path_with_progress(
1075                staging,
1076                odb,
1077                &ingest_opts,
1078                Some(&mut progress),
1079                Some(cancel),
1080            )?
1081        } else {
1082            install_pack_bytes_with_progress(
1083                built_pack.bytes,
1084                odb,
1085                &ingest_opts,
1086                Some(&mut progress),
1087                Some(cancel),
1088            )?
1089        };
1090        report.packs_written = 1;
1091        report.objects = ingested.object_ids.len();
1092        let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
1093            operation: "repack",
1094        })?;
1095
1096        let idx_path = pack_path.with_extension("idx");
1097        if cfg
1098            .as_ref()
1099            .is_none_or(|c| c.pack_write_reverse_index_default())
1100        {
1101            write_pack_rev_sidecar_for_index_path(&idx_path)?;
1102        }
1103
1104        if opts.write_bitmap && promised.is_empty() && !AlternateSources::load(odb)?.in_use() {
1105            if opts.incremental && packs_before > 0 {
1106                // Per-pack bitmaps require reachability closure within one pack; incremental
1107                // repack keeps older packs and adds a new layer. Write a multi-pack-index
1108                // bitmap covering all packs instead of a sidecar on the new pack alone.
1109                let version = match cfg.as_ref() {
1110                    Some(c) => midx_version_from_config(c)?,
1111                    None => None,
1112                };
1113                let midx_opts = WriteMultiPackIndexOptions {
1114                    write_bitmap: true,
1115                    version,
1116                    ..WriteMultiPackIndexOptions::default()
1117                };
1118                let WriteMultiPackIndexResult { bitmap: _ } =
1119                    write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
1120            } else {
1121                let bitmap_opts = PackBitmapWriteOptions {
1122                    reachability_roots: Some(roots.clone()),
1123                    ..PackBitmapWriteOptions::default()
1124                };
1125                let now = opts
1126                    .now
1127                    .map(offset_to_system_time)
1128                    .unwrap_or(SystemTime::UNIX_EPOCH);
1129                match PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now) {
1130                    Ok(_) => {}
1131                    Err(PackBitmapWriteError::NotClosed { .. }) => {
1132                        // Pack was built from an earlier root snapshot; live tips moved on
1133                        // (concurrent receive-pack). Skip the bitmap this run.
1134                    }
1135                    Err(e) => return Err(Error::CorruptObject(e.to_string())),
1136                }
1137            }
1138        }
1139
1140        let retention = if opts.keep_unreachable {
1141            UnreachablePackRetention::KeepAll
1142        } else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1143            UnreachablePackRetention::KeepGraceProtected(cutoff)
1144        } else {
1145            UnreachablePackRetention::DropAll
1146        };
1147        // Incremental repack keeps every existing pack, so nothing is superseded.
1148        if !opts.incremental {
1149            odb.loosen_unreachable_from_superseded_packs(
1150                &pack_dir,
1151                &pack_path,
1152                &reachable,
1153                retention,
1154                &retained_packs,
1155                &pack_inventory,
1156            )?;
1157        }
1158
1159        // Drop cached pack/index mmaps before deleting superseded pack artifacts (Windows).
1160        odb.invalidate_packs();
1161
1162        report.packs_removed = if opts.incremental {
1163            0
1164        } else {
1165            remove_superseded_pack_files_retaining(
1166                &pack_dir,
1167                &pack_path,
1168                &retained_packs,
1169                &pack_inventory,
1170            )?
1171        };
1172
1173        prune_packed_loose =
1174            prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
1175
1176        installed_pack = Some(pack_path);
1177
1178        if opts.write_commit_graph {
1179            write_commit_graph(
1180                repo,
1181                &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
1182                    repo,
1183                )),
1184            )?;
1185        }
1186    }
1187
1188    let mut unreachable_prune_ms = 0u64;
1189    let mut unreachable_prune_ran = false;
1190    if !opts.keep_unreachable {
1191        let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
1192        let prune_start = Instant::now();
1193        unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
1194        unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
1195        unreachable_prune_ran = true;
1196    }
1197
1198    report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
1199    Ok(RepackDetail {
1200        report,
1201        pack_path: installed_pack,
1202        prune_packed_loose,
1203        unreachable_loose,
1204        unreachable_prune_ms,
1205        unreachable_prune_ran,
1206    })
1207}
1208
1209/// Like [`repack`], with legacy [`RepackStats`] fields for the CLI.
1210pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
1211    repack_all_with_cancel(repo, opts, &NeverCancel)
1212}
1213
1214/// Like [`repack_all`], but returns [`Error::Cancelled`] before publishing a new pack.
1215pub fn repack_all_with_cancel(
1216    repo: &Repository,
1217    opts: &RepackOptions,
1218    cancel: &dyn Cancel,
1219) -> Result<RepackStats> {
1220    let odb = repo.odb();
1221    let objects_dir = odb
1222        .files_objects_dir()
1223        .ok_or(Error::UnsupportedObjectStore {
1224            operation: "repack",
1225        })?;
1226    let pack_dir = objects_dir.join("pack");
1227    let mut stats = RepackStats {
1228        loose_before: count_loose_objects(odb)?,
1229        packs_before: count_pack_files(&pack_dir),
1230        ..RepackStats::default()
1231    };
1232    if opts.dry_run {
1233        let count = repack_detail(repo, opts, cancel)?.report.objects;
1234        stats.objects_packed = (count > 0).then_some(count);
1235        return Ok(stats);
1236    }
1237    let detail = repack_detail(repo, opts, cancel)?;
1238    let report = detail.report;
1239    stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
1240    stats.removed_pack_files = report.packs_removed;
1241    stats.pruned_loose = detail.prune_packed_loose;
1242    stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
1243    stats.unreachable_loose_kept = detail.unreachable_loose.kept;
1244    stats.unreachable_prune_ms = detail.unreachable_prune_ms;
1245    stats.unreachable_prune_ran = detail.unreachable_prune_ran;
1246    stats.pack_path = detail.pack_path;
1247    stats.pack_bytes = stats
1248        .pack_path
1249        .as_ref()
1250        .and_then(|p| p.metadata().ok().map(|m| m.len()));
1251    Ok(stats)
1252}
1253
1254fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
1255    SystemTime::UNIX_EPOCH
1256        + std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
1257}
1258
1259fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
1260    match policy {
1261        GcLoosePrunePolicy::Disabled => {
1262            repack.keep_unreachable = true;
1263        }
1264        GcLoosePrunePolicy::Enabled { keep_newer_than } => {
1265            repack.keep_unreachable = false;
1266            repack.prune_older_than = keep_newer_than.map(|t| {
1267                let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
1268                OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
1269                    .unwrap_or(OffsetDateTime::UNIX_EPOCH)
1270            });
1271        }
1272    }
1273}
1274
1275/// Last `repack.writeBitmaps` or `pack.writeBitmaps` entry in load order (Git synonym).
1276fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1277    let repack_key = canonical_key("repack.writeBitmaps").ok();
1278    let pack_key = canonical_key("pack.writeBitmaps").ok();
1279    let mut last: Option<bool> = None;
1280    for entry in config.entries() {
1281        let key = entry.key.as_str();
1282        if repack_key.as_deref() == Some(key) || pack_key.as_deref() == Some(key) {
1283            let raw = entry.value.as_deref().unwrap_or("true").trim().to_owned();
1284            last = Some(parse_bool(&raw).map_err(|e| e.for_key(key))?);
1285        }
1286    }
1287    Ok(last.unwrap_or_else(|| repo.work_tree().is_none()))
1288}
1289
1290fn gc_should_pack_refs(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1291    let Some(spec) = config.get("gc.packRefs") else {
1292        return Ok(true);
1293    };
1294    if spec.eq_ignore_ascii_case("notbare") {
1295        return Ok(repo.work_tree().is_some());
1296    }
1297    Ok(parse_bool(&spec).map_err(|e| e.for_key("gc.packRefs"))?)
1298}
1299
1300fn gc_should_write_commit_graph(config: &ConfigSet) -> Result<bool> {
1301    if config.get_bool("gc.writeCommitGraph").transpose()? == Some(false) {
1302        return Ok(false);
1303    }
1304    Ok(config.get_bool("core.commitGraph").transpose()? != Some(false))
1305}
1306
1307/// Loose objects reachable from the maintenance roots: what Git's incremental `repack -d`
1308/// packs. Unreachable loose objects stay loose so `gc.pruneExpire` can expire them.
1309fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1310    let loose = odb.enumerate_local_loose_objects()?;
1311    if loose.is_empty() {
1312        return Ok(Vec::new());
1313    }
1314    let promised = reachable.promised()?;
1315    let reachable = reachable.get()?;
1316    let alternates = AlternateSources::load(odb)?;
1317    Ok(loose
1318        .into_iter()
1319        .map(|(oid, _)| oid)
1320        .filter(|oid| {
1321            reachable.contains(oid) && !promised.contains(oid) && !alternates.contains(oid)
1322        })
1323        .collect())
1324}
1325
1326/// [`reachable_loose_objects`] in pack order, with the path name hash of each tree and
1327/// blob found under a loose commit so versions of one file meet in the delta window.
1328///
1329/// Only loose objects are read: the walk follows loose commits from `roots` (newest
1330/// first) and descends into loose trees, stopping wherever history or a subtree is
1331/// already packed. Objects it does not reach keep their place after the walked ones
1332/// without a name hash.
1333fn named_reachable_loose_objects(
1334    odb: &Odb,
1335    reachable: &LazyReachable<'_>,
1336    roots: &[ObjectId],
1337) -> Result<(Vec<ObjectId>, HashMap<ObjectId, u32>)> {
1338    let loose = reachable_loose_objects(odb, reachable)?;
1339    let mut pending: OidHashSet = loose.iter().copied().collect();
1340    let mut order = Vec::with_capacity(loose.len());
1341    let mut names = HashMap::new();
1342    let mut commits = Vec::new();
1343    let mut root_trees = Vec::new();
1344    let mut stack: Vec<ObjectId> = roots
1345        .iter()
1346        .filter(|oid| pending.contains(*oid))
1347        .copied()
1348        .collect();
1349    let mut seen = OidHashSet::default();
1350    while let Some(oid) = stack.pop() {
1351        if !seen.insert(oid) {
1352            continue;
1353        }
1354        match odb.read_info(&oid)?.kind {
1355            ObjectKind::Commit => {
1356                let obj = odb.read(&oid)?;
1357                let commit = CommitRef::parse(&obj.data)?;
1358                commits.push((
1359                    commit.committer_unix_seconds_for_ordering(),
1360                    oid,
1361                    commit.tree(),
1362                ));
1363                stack.extend(commit.parents().iter().filter(|p| pending.contains(*p)));
1364            }
1365            ObjectKind::Tag => {
1366                let obj = odb.read(&oid)?;
1367                pending.remove(&oid);
1368                order.push(oid);
1369                stack.extend(tag_object_line_oid(&obj.data).filter(|t| pending.contains(t)));
1370            }
1371            ObjectKind::Tree => root_trees.push(oid),
1372            ObjectKind::Blob => {}
1373        }
1374    }
1375    commits.sort_by_key(|&(time, _, _)| std::cmp::Reverse(time));
1376    for &(_, oid, _) in &commits {
1377        pending.remove(&oid);
1378        order.push(oid);
1379    }
1380    let hash_len = odb.hash_algo().len();
1381    let mut trees: Vec<(ObjectId, Option<u32>)> = Vec::new();
1382    for tree in commits.iter().map(|&(_, _, tree)| tree).chain(root_trees) {
1383        trees.push((tree, None));
1384        while let Some((tree, name_hash)) = trees.pop() {
1385            if !pending.remove(&tree) {
1386                continue;
1387            }
1388            order.push(tree);
1389            if let Some(hash) = name_hash {
1390                names.insert(tree, hash);
1391            }
1392            let obj = odb.read(&tree)?;
1393            for entry in TreeRefIter::with_oid_len(&obj.data, hash_len) {
1394                let entry = entry?;
1395                if FileMode::new(entry.mode).is_gitlink() || !pending.contains(&entry.oid) {
1396                    continue;
1397                }
1398                let hash = pack_name_hash_child(name_hash, entry.name);
1399                if entry.mode & S_IFMT == S_IFDIR {
1400                    trees.push((entry.oid, Some(hash)));
1401                } else {
1402                    pending.remove(&entry.oid);
1403                    order.push(entry.oid);
1404                    names.insert(entry.oid, hash);
1405                }
1406            }
1407        }
1408    }
1409    order.extend(loose.into_iter().filter(|oid| pending.contains(oid)));
1410    Ok((order, names))
1411}
1412
1413/// Whether maintenance should skip rebuilding packs when [`RepackOptions::auto_skip`] is set.
1414///
1415/// Used by [`gc`]: skip when a single pack already holds all objects and nothing is loose.
1416/// Multiple packs never skip (consolidation required), even when `gc.autoPackLimit` is zero.
1417fn should_auto_skip_repack(
1418    loose_objects: usize,
1419    pack_count: usize,
1420    incremental: bool,
1421    auto_skip: bool,
1422    _config: Option<&ConfigSet>,
1423) -> bool {
1424    if !auto_skip || incremental {
1425        return false;
1426    }
1427    if pack_count > 1 {
1428        return false;
1429    }
1430    if pack_count == 1 {
1431        return loose_objects == 0;
1432    }
1433    loose_objects == 0
1434}
1435
1436/// True when skipping repack would leave unreachable objects in pack storage that repack would
1437/// remove under `opts` (for example expired unreachable commits when `gc.pruneExpire=now`).
1438///
1439/// The reachability walk only runs once some pack is old enough to have objects dropped.
1440fn skipping_repack_would_retain_prunable_packed_objects(
1441    odb: &Odb,
1442    pack_dir: &Path,
1443    reachable: &LazyReachable<'_>,
1444    opts: &RepackOptions,
1445) -> Result<bool> {
1446    if opts.keep_unreachable {
1447        return Ok(false);
1448    }
1449    let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1450        UnreachablePackRetention::KeepGraceProtected(cutoff)
1451    } else {
1452        UnreachablePackRetention::DropAll
1453    };
1454    let indexes = odb.local_pack_indexes()?;
1455    for idx in indexes {
1456        if idx.is_promisor || pack_has_keep_marker(pack_dir, &idx.pack_path) {
1457            continue;
1458        }
1459        let pack_mtime = fs::metadata(&idx.pack_path)
1460            .and_then(|m| m.modified())
1461            .unwrap_or(SystemTime::UNIX_EPOCH);
1462        let drop_unreachable_from_pack = match retention {
1463            UnreachablePackRetention::KeepAll => false,
1464            UnreachablePackRetention::DropAll => true,
1465            UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1466        };
1467        if !drop_unreachable_from_pack {
1468            continue;
1469        }
1470        let reachable = reachable.get()?;
1471        for entry in idx.iter() {
1472            let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1473                continue;
1474            };
1475            if !reachable.contains(&oid) {
1476                return Ok(true);
1477            }
1478        }
1479    }
1480    Ok(false)
1481}
1482
1483/// Delete unreachable loose objects using `gc.pruneExpire` or an explicit cutoff.
1484///
1485/// Also removes stale temporary object paths (interrupted fetch/repack/index-pack leftovers)
1486/// under the same expiry rules as Git's `git gc` / `git prune` temporary-file sweep.
1487///
1488/// # Errors
1489///
1490/// Propagates config parse failures, unsupported ODB backends, and I/O errors.
1491pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1492    let odb = repo.odb();
1493    odb.require_files_primary("prune")?;
1494    let cfg = repo.config()?;
1495    let now = gc_now_unix(0);
1496    let keep_newer_than = match opts.older_than {
1497        Some(t) => Some(t),
1498        None => match gc_loose_prune_policy(&cfg, now)? {
1499            GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1500            GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1501        },
1502    };
1503    let roots = collect_maintenance_object_roots(repo)?;
1504    let shallow = load_shallow_boundaries(repo.git_dir());
1505    let mut stats = prune_loose_unreachable_with(
1506        odb,
1507        &LazyReachable::new(odb, &roots, &shallow),
1508        keep_newer_than,
1509        opts.dry_run,
1510    )?;
1511    let objects_dir = odb
1512        .files_objects_dir()
1513        .ok_or(Error::UnsupportedObjectStore {
1514            operation: "prune temporary objects",
1515        })?;
1516    let temp =
1517        prune_stale_temporary_objects_with_cutoff(objects_dir, keep_newer_than, opts.dry_run)?;
1518    stats.pruned += temp.pruned;
1519    stats.kept += temp.kept;
1520    Ok(stats)
1521}
1522
1523/// Remove stale temporary object files and directories left by interrupted maintenance or
1524/// fetch operations.
1525///
1526/// Uses the same cutoff semantics as unreachable loose-object pruning: when
1527/// `keep_newer_than` is `None` (`gc.pruneExpire=now|all`), every temporary entry is
1528/// eligible; otherwise only entries whose modification time is not newer than the cutoff
1529/// are removed.
1530fn prune_stale_temporary_objects_with_cutoff(
1531    objects_dir: &Path,
1532    keep_newer_than: Option<SystemTime>,
1533    dry_run: bool,
1534) -> Result<PruneStats> {
1535    let mut stats = PruneStats::default();
1536    let mut consider = |path: &Path| -> Result<()> {
1537        let meta = match fs::symlink_metadata(path) {
1538            Ok(m) => m,
1539            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1540            Err(e) => return Err(Error::Io(e)),
1541        };
1542        if temporary_object_expired(&meta, keep_newer_than) {
1543            if dry_run {
1544                stats.pruned += 1;
1545            } else if meta.is_dir() {
1546                fs::remove_dir_all(path).map_err(Error::Io)?;
1547                stats.pruned += 1;
1548            } else {
1549                fs::remove_file(path).map_err(Error::Io)?;
1550                stats.pruned += 1;
1551            }
1552        } else {
1553            stats.kept += 1;
1554        }
1555        Ok(())
1556    };
1557
1558    if let Ok(entries) = fs::read_dir(objects_dir) {
1559        for entry in entries.flatten() {
1560            let name = entry.file_name();
1561            let name = name.to_string_lossy();
1562            if name.starts_with("tmp_") {
1563                consider(&entry.path())?;
1564            } else if name.len() == 2 && name.as_bytes().iter().all(|b| b.is_ascii_hexdigit()) {
1565                if let Ok(loose) = fs::read_dir(entry.path()) {
1566                    for loose_entry in loose.flatten() {
1567                        let loose_name = loose_entry.file_name();
1568                        if loose_name.to_string_lossy().starts_with("tmp_obj_") {
1569                            consider(&loose_entry.path())?;
1570                        }
1571                    }
1572                }
1573            }
1574        }
1575    }
1576
1577    let pack_dir = objects_dir.join("pack");
1578    if let Ok(entries) = fs::read_dir(&pack_dir) {
1579        for entry in entries.flatten() {
1580            let name = entry.file_name();
1581            let name = name.to_string_lossy();
1582            if name.starts_with("tmp_") {
1583                consider(&entry.path())?;
1584                continue;
1585            }
1586            if !is_grit_pack_install_stage_name(&name) {
1587                continue;
1588            }
1589            let meta = match fs::symlink_metadata(entry.path()) {
1590                Ok(m) => m,
1591                Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
1592                Err(e) => return Err(Error::Io(e)),
1593            };
1594            if meta.is_dir() {
1595                consider(&entry.path())?;
1596            }
1597        }
1598    }
1599
1600    let incoming = objects_dir.join("incoming");
1601    if let Ok(entries) = fs::read_dir(&incoming) {
1602        for entry in entries.flatten() {
1603            let name = entry.file_name();
1604            let name = name.to_string_lossy();
1605            if name.starts_with("pack-receive-") && name.ends_with(".tmp") {
1606                consider(&entry.path())?;
1607            }
1608        }
1609    }
1610    Ok(stats)
1611}
1612
1613/// Grit index-pack staging directory: `.pack-<oid>-install-<pid>_<seq>`.
1614fn is_grit_pack_install_stage_name(name: &str) -> bool {
1615    name.starts_with(".pack-") && name.contains("-install-")
1616}
1617
1618fn temporary_object_expired(meta: &fs::Metadata, keep_newer_than: Option<SystemTime>) -> bool {
1619    match keep_newer_than {
1620        None => true,
1621        Some(cutoff) => meta
1622            .modified()
1623            .ok()
1624            .is_none_or(|modified| modified <= cutoff),
1625    }
1626}
1627
1628fn duration_ms(d: std::time::Duration) -> u64 {
1629    d.as_millis() as u64
1630}
1631
1632/// Run pack-refs, reflog expiry, repack, prune, and commit-graph maintenance.
1633///
1634/// Reclaims stale `next-index-<pid>` hook index copies (from interrupted commits) before any
1635/// phase, including when [`RepackOptions::dry_run`] is set (dry-run counts but does not delete).
1636///
1637/// Unless [`RepackOptions::dry_run`] is set, holds the repository's `gc.pid` lock for the
1638/// whole run, so it never overlaps another grit or Git gc (see [`GcLock`]).
1639///
1640/// # Errors
1641///
1642/// [`Error::MaintenanceRunning`] when another process holds the `gc.pid` lock, plus failures
1643/// from each maintenance step.
1644pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1645    gc_with_cancel(repo, opts, &NeverCancel)
1646}
1647
1648/// Like [`gc`], but returns [`Error::Cancelled`] between maintenance phases and before
1649/// publishing a new pack.
1650pub fn gc_with_cancel(repo: &Repository, opts: &GcOptions, cancel: &dyn Cancel) -> Result<GcStats> {
1651    repo.odb().require_files_primary("gc")?;
1652    let now = gc_now_unix(opts.now_unix);
1653    let _lock = if opts.repack.dry_run {
1654        None
1655    } else {
1656        match GcLock::try_acquire(repo, now)? {
1657            GcLockAttempt::Acquired(lock) => Some(lock),
1658            GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1659        }
1660    };
1661    gc_locked(repo, opts, now, cancel)
1662}
1663
1664/// Why automatic maintenance is due, as decided by [`auto_gc_needed`].
1665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1666pub enum AutoGcReason {
1667    /// The loose-object estimate is above `gc.auto`. [`gc_auto`] packs the reachable loose
1668    /// objects into one new pack and leaves existing packs alone.
1669    TooManyLoose {
1670        /// Loose objects in the sampled fan-out directory (`objects/17`) times 256.
1671        estimate: usize,
1672        /// Effective `gc.auto` threshold.
1673        limit: usize,
1674    },
1675    /// There are more non-`.keep` packs than `gc.autoPackLimit`. [`gc_auto`] consolidates
1676    /// every pack into one.
1677    TooManyPacks {
1678        /// Local packs without a `.keep` marker.
1679        count: usize,
1680        /// Effective `gc.autoPackLimit`.
1681        limit: usize,
1682    },
1683}
1684
1685/// Fan-out directory sampled to estimate the loose-object count, as Git documents for `gc.auto`.
1686const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1687
1688/// Decide whether automatic maintenance is due, using Git's `gc --auto` heuristics.
1689///
1690/// Pack consolidation wins when the number of local packs without a `.keep` marker is above
1691/// [`ConfigSet::gc_auto_pack_limit`]. Otherwise the loose-object count is estimated from a
1692/// single fan-out directory (`objects/17`) and compared with [`ConfigSet::gc_auto_threshold`],
1693/// so the check costs two directory reads however large the repository is. `gc.auto <= 0`
1694/// turns both checks off. Repositories without a files object store never need it.
1695///
1696/// This does not look at `maintenance.auto`; callers that start maintenance after a command
1697/// check [`ConfigSet::maintenance_auto`] themselves.
1698///
1699/// # Errors
1700///
1701/// Config load failures and I/O errors other than a missing directory.
1702pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1703    let cfg = repo.config()?;
1704    let Some(threshold) = cfg.gc_auto_threshold() else {
1705        return Ok(None);
1706    };
1707    let Some(objects_dir) = repo.odb().files_objects_dir() else {
1708        return Ok(None);
1709    };
1710    if let Some(limit) = cfg.gc_auto_pack_limit() {
1711        let count = count_pack_files(&objects_dir.join("pack"));
1712        if count > limit {
1713            return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1714        }
1715    }
1716    let name_len = repo.odb().hash_algo().hex_len() - 2;
1717    let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1718    if sampled > threshold.div_ceil(256) {
1719        return Ok(Some(AutoGcReason::TooManyLoose {
1720            estimate: sampled.saturating_mul(256),
1721            limit: threshold,
1722        }));
1723    }
1724    Ok(None)
1725}
1726
1727/// Count entries of one loose-object fan-out directory that look like object files.
1728fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1729    let entries = match fs::read_dir(dir) {
1730        Ok(entries) => entries,
1731        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1732        Err(e) => return Err(Error::Io(e)),
1733    };
1734    Ok(entries
1735        .filter_map(|e| e.ok())
1736        .filter(|e| {
1737            let name = e.file_name();
1738            let name = name.as_encoded_bytes();
1739            name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1740        })
1741        .count())
1742}
1743
1744/// Result of [`gc_auto`].
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746#[non_exhaustive]
1747pub enum AutoGcOutcome {
1748    /// Neither threshold is exceeded (or `gc.auto` is off); nothing ran.
1749    NotNeeded,
1750    /// Another process holds the `gc.pid` lock; nothing ran.
1751    AlreadyRunning(GcLockHolder),
1752    /// The `pre-auto-gc` hook exited non-zero; nothing ran.
1753    DeclinedByHook {
1754        /// Exit status of the hook.
1755        status: i32,
1756        /// What the hook wrote, when hook output is captured ([`Repository::hook_stdio`]).
1757        output: crate::hooks::HookOutput,
1758    },
1759    /// Maintenance ran.
1760    Ran {
1761        /// Which threshold triggered the run.
1762        reason: AutoGcReason,
1763        /// What each step did.
1764        stats: Box<GcStats>,
1765    },
1766}
1767
1768/// Run maintenance only when Git's `gc --auto` heuristics say it is due ([`auto_gc_needed`]).
1769///
1770/// When too many loose objects triggered the run, the reachable ones are packed into one new
1771/// pack without rewriting existing packs and without a bitmap, so the cost scales with the
1772/// loose objects rather than the repository. Unreachable loose objects stay loose until
1773/// `gc.pruneExpire` lets the prune step delete them, as in Git. Too many packs runs a full consolidating [`gc`]. Either way the
1774/// other `opts` steps (pack-refs, reflog expiry, unreachable prune under `gc.pruneExpire`,
1775/// commit-graph) run as in [`gc`].
1776///
1777/// Before doing any work this takes the `gc.pid` lock ([`GcLock`]) and runs the `pre-auto-gc`
1778/// hook; a held lock or a failing hook ends the call without changes. With
1779/// [`RepackOptions::dry_run`] neither happens and the returned stats describe the planned work.
1780///
1781/// The library never forks: callers that honor [`ConfigSet::gc_auto_detach`] start a separate
1782/// process that calls this function.
1783///
1784/// # Errors
1785///
1786/// Config, hook spawn, lock and I/O failures, and any error from the maintenance steps.
1787pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1788    let Some(reason) = auto_gc_needed(repo)? else {
1789        return Ok(AutoGcOutcome::NotNeeded);
1790    };
1791    let now = gc_now_unix(opts.now_unix);
1792    let dry_run = opts.repack.dry_run;
1793    let _lock = if dry_run {
1794        None
1795    } else {
1796        match GcLock::try_acquire(repo, now)? {
1797            GcLockAttempt::Acquired(lock) => Some(lock),
1798            GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1799        }
1800    };
1801    if !dry_run {
1802        if let crate::hooks::HookResult::Failed { status, output } =
1803            crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1804        {
1805            return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1806        }
1807    }
1808    let mut run_opts = opts.clone();
1809    if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1810        run_opts.repack.incremental = true;
1811        run_opts.repack.write_bitmap = false;
1812    }
1813    let stats = gc_locked(repo, &run_opts, now, &NeverCancel)?;
1814    Ok(AutoGcOutcome::Ran {
1815        reason,
1816        stats: Box::new(stats),
1817    })
1818}
1819
1820/// Process recorded in `gc.pid` by a maintenance run that is still in progress.
1821#[derive(Debug, Clone, PartialEq, Eq)]
1822pub struct GcLockHolder {
1823    /// Process id from `gc.pid`, when it could be read.
1824    pub pid: Option<u64>,
1825    /// Host name from `gc.pid`, when it could be read.
1826    pub host: Option<String>,
1827}
1828
1829/// Outcome of [`GcLock::try_acquire`].
1830#[derive(Debug)]
1831#[must_use]
1832pub enum GcLockAttempt {
1833    /// This process now owns `gc.pid`.
1834    Acquired(GcLock),
1835    /// Another live maintenance run owns it.
1836    Held(GcLockHolder),
1837}
1838
1839/// How long a `gc.pid` written by another run is honored before it is treated as stale.
1840const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1841
1842/// Repository-wide maintenance lock, compatible with Git's `gc.pid`.
1843///
1844/// The lock file holds `"<pid> <hostname>"` and is removed when this value is dropped. An
1845/// existing `gc.pid` counts as held while it is younger than 12 hours and either names another
1846/// host or a process on this host that is still running; older or dead entries are taken over.
1847#[derive(Debug)]
1848pub struct GcLock {
1849    path: PathBuf,
1850}
1851
1852impl GcLock {
1853    /// Try to take the `gc.pid` lock in the repository's common directory without waiting.
1854    ///
1855    /// `now_unix` is the wall clock used to decide whether an existing `gc.pid` is stale.
1856    ///
1857    /// # Errors
1858    ///
1859    /// I/O failures while creating, writing or renaming the lock file.
1860    pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1861        let path = repo.common_git_dir().join("gc.pid");
1862        let mut lock = match crate::lockfile::LockFile::acquire(
1863            &path.with_extension("pid.lock"),
1864            crate::lockfile::LockWait::Immediately,
1865        ) {
1866            Ok(lock) => lock,
1867            Err(crate::lockfile::LockError::Held { .. }) => {
1868                return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1869                    GcLockHolder {
1870                        pid: None,
1871                        host: None,
1872                    },
1873                )));
1874            }
1875            Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1876        };
1877        let host = local_host_name();
1878        if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1879            lock.rollback();
1880            return Ok(GcLockAttempt::Held(holder));
1881        }
1882        use std::io::Write as _;
1883        write!(lock, "{} {host}", std::process::id())?;
1884        lock.commit_to(&path).map_err(|e| match e {
1885            crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1886            held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1887        })?;
1888        Ok(GcLockAttempt::Acquired(GcLock { path }))
1889    }
1890
1891    /// The maintenance run currently holding `gc.pid`, without taking the lock.
1892    ///
1893    /// Uses the same staleness rules as [`Self::try_acquire`], so `None` means an attempt would
1894    /// most likely succeed. Lets a caller avoid starting a process that would only find the
1895    /// lock held.
1896    #[must_use]
1897    pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1898        let path = repo.common_git_dir().join("gc.pid");
1899        live_gc_pid_holder(&path, &local_host_name(), now_unix)
1900    }
1901}
1902
1903impl Drop for GcLock {
1904    fn drop(&mut self) {
1905        let _ = fs::remove_file(&self.path);
1906    }
1907}
1908
1909fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1910    let raw = fs::read_to_string(path).ok()?;
1911    let mut fields = raw.split_whitespace();
1912    let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1913    let host = fields.next().map(str::to_owned);
1914    Some(GcLockHolder { pid, host })
1915}
1916
1917/// The holder recorded in `gc.pid` when it still blocks a new run on `host`.
1918fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1919    let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1920    let mtime = OffsetDateTime::from(mtime);
1921    let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1922    if now - mtime > GC_PID_STALE_AFTER {
1923        return None;
1924    }
1925    let holder = read_gc_pid(path)?;
1926    let pid = holder.pid?;
1927    let same_host = holder.host.as_deref() == Some(host);
1928    if same_host && !crate::lockfile::is_process_running(pid) {
1929        return None;
1930    }
1931    Some(holder)
1932}
1933
1934/// Host name as Git records it in `gc.pid` (`gethostname`), so either tool can tell whether
1935/// the other's lock belongs to this machine.
1936fn local_host_name() -> String {
1937    #[cfg(unix)]
1938    {
1939        if let Ok(uts) = nix::sys::utsname::uname() {
1940            if let Some(name) = uts.nodename().to_str() {
1941                return name.to_owned();
1942            }
1943        }
1944    }
1945    #[cfg(windows)]
1946    {
1947        if let Some(name) = windows_dns_host_name() {
1948            return name;
1949        }
1950    }
1951    "localhost".to_owned()
1952}
1953
1954/// The DNS host name, which is what Winsock `gethostname` (used by Git for Windows) returns.
1955#[cfg(windows)]
1956fn windows_dns_host_name() -> Option<String> {
1957    use windows_sys::Win32::System::SystemInformation::{
1958        ComputerNameDnsHostname, GetComputerNameExW,
1959    };
1960    let mut len: u32 = 0;
1961    // SAFETY: a null buffer with zero length only asks for the required size in `len`.
1962    unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1963    if len == 0 {
1964        return None;
1965    }
1966    let mut buf = vec![0u16; len as usize];
1967    // SAFETY: `buf` has room for the `len` UTF-16 units the previous call asked for.
1968    let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1969    (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1970}
1971
1972fn gc_locked(
1973    repo: &Repository,
1974    opts: &GcOptions,
1975    now: i64,
1976    cancel: &dyn Cancel,
1977) -> Result<GcStats> {
1978    poll_cancel(&mut NullProgress, cancel)?;
1979    let mut stats = GcStats::default();
1980    let dry_run = opts.repack.dry_run;
1981    let _ = crate::porcelain::commit::reap_stale_next_index_files(repo, dry_run)?;
1982    let cfg = repo.config()?;
1983    let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1984    // Settled before any phase, so a bad config value changes nothing.
1985    let drop_plan = if opts.drop_large_blobs {
1986        Some(drop_blobs::plan_drop_for_gc(&cfg, opts.now_unix)?)
1987    } else {
1988        None
1989    };
1990    if opts.pack_refs && gc_should_pack_refs(repo, &cfg)? && !dry_run {
1991        poll_cancel(&mut NullProgress, cancel)?;
1992        let start = Instant::now();
1993        stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1994        stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1995    }
1996
1997    if opts.expire_reflog {
1998        let start = Instant::now();
1999        stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
2000        stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
2001    }
2002
2003    let mut repack_opts = opts.repack.clone();
2004    apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
2005    if repack_opts.now.is_none() {
2006        repack_opts.now =
2007            Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
2008    }
2009    // Incremental runs skip bitmaps, as Git's `gc --auto` does: a bitmap covers the whole
2010    // object graph and costs far more than packing the loose objects.
2011    if !repack_opts.write_bitmap && !repack_opts.incremental {
2012        repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg)?;
2013    }
2014    poll_cancel(&mut NullProgress, cancel)?;
2015    let repack_start = Instant::now();
2016    stats.repack = repack_all_with_cancel(repo, &repack_opts, cancel)?;
2017    let repack_total_ms = duration_ms(repack_start.elapsed());
2018    stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
2019    stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
2020
2021    // Repack leaves promisor packs as they are, so the drop phase sees them whole.
2022    if let Some(plan) = drop_plan {
2023        let start = Instant::now();
2024        stats.drop_large_blobs = Some(drop_blobs::run_drop_for_gc(
2025            repo,
2026            &cfg,
2027            plan,
2028            dry_run,
2029            opts.repack.threads,
2030        )?);
2031        stats.phase_timings.drop_large_blobs_ms = duration_ms(start.elapsed());
2032    }
2033
2034    // Repack prunes unreachable loose objects itself (sharing its reachability walk), so gc
2035    // only reports the outcome.
2036    stats.prune_ran = stats.repack.unreachable_prune_ran;
2037    stats.prune = PruneStats {
2038        pruned: stats.repack.unreachable_loose_pruned,
2039        kept: stats.repack.unreachable_loose_kept,
2040    };
2041
2042    if let GcLoosePrunePolicy::Enabled { keep_newer_than } = &prune_policy {
2043        let temp = prune_stale_temporary_objects_with_cutoff(
2044            repo.odb()
2045                .files_objects_dir()
2046                .ok_or(Error::UnsupportedObjectStore { operation: "gc" })?,
2047            *keep_newer_than,
2048            dry_run,
2049        )?;
2050        stats.prune.pruned += temp.pruned;
2051        stats.prune.kept += temp.kept;
2052    }
2053
2054    if dry_run {
2055        return Ok(stats);
2056    }
2057
2058    if opts.write_commit_graph && gc_should_write_commit_graph(&cfg)? {
2059        let start = Instant::now();
2060        stats.commit_graph = Some(write_commit_graph(
2061            repo,
2062            &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
2063        )?);
2064        stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
2065    }
2066    Ok(stats)
2067}
2068
2069/// How [`gc`] applies loose-object pruning for `gc.pruneExpire`.
2070enum GcLoosePrunePolicy {
2071    /// `gc.pruneExpire=never` — do not delete unreachable loose objects.
2072    Disabled,
2073    /// Prune unreachable loose objects not protected by the grace cutoff.
2074    Enabled {
2075        /// When `Some(t)`, only delete loose objects strictly older than `t` (Git grace window).
2076        keep_newer_than: Option<SystemTime>,
2077    },
2078}
2079
2080fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
2081    let spec = config
2082        .get("gc.pruneExpire")
2083        .map(|s| s.trim().to_owned())
2084        .filter(|s| !s.is_empty())
2085        .unwrap_or_else(|| "2.weeks.ago".to_owned());
2086    if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
2087        return Ok(GcLoosePrunePolicy::Disabled);
2088    }
2089    // Git expires everything for `now` and `all`, including objects written this second.
2090    if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
2091        return Ok(GcLoosePrunePolicy::Enabled {
2092            keep_newer_than: None,
2093        });
2094    }
2095    let mut err = 0;
2096    let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
2097    if err != 0 {
2098        return Err(Error::Config(ConfigError::InvalidDate {
2099            key: "gc.pruneExpire".to_owned(),
2100            value: spec,
2101        }));
2102    }
2103    let keep_newer_than = SystemTime::UNIX_EPOCH
2104        .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
2105    Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
2106}
2107
2108fn gc_now_unix(now_unix: i64) -> i64 {
2109    if now_unix != 0 {
2110        return now_unix;
2111    }
2112    time::OffsetDateTime::now_utc().unix_timestamp()
2113}
2114
2115fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
2116    let cfg = repo.config()?;
2117    let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
2118    let params = ReflogExpireParams {
2119        stale_fix: false,
2120        dry_run,
2121        verbose: false,
2122    };
2123    let mut total = 0usize;
2124    let cache = ReflogExpiryCache::default();
2125    for refname in reflog_refs_on_store(repo.refs())? {
2126        let result = expire_reflog_git_with_cache(
2127            repo,
2128            &refname,
2129            &params,
2130            None,
2131            None,
2132            &gc_cfg.patterns,
2133            gc_cfg.global_total,
2134            gc_cfg.global_unreachable,
2135            now,
2136            &cache,
2137        )?;
2138        total += result.pruned;
2139    }
2140    Ok(total)
2141}
2142
2143fn count_loose_objects(odb: &Odb) -> Result<usize> {
2144    Ok(odb.enumerate_local_loose_objects()?.len())
2145}
2146
2147/// Build the all-into-one repack pack for `roots`, stopping history at the `shallow`
2148/// boundary commits.
2149#[allow(clippy::too_many_arguments)]
2150fn build_repack_pack(
2151    odb: &Odb,
2152    pack_dir: &Path,
2153    roots: &[ObjectId],
2154    shallow: &HashSet<ObjectId>,
2155    keep_unreachable: bool,
2156    opts: &PackBuildOptions,
2157    threads: Option<usize>,
2158    cfg: Option<&ConfigSet>,
2159    promised: &HashSet<ObjectId>,
2160) -> Result<SerializedDeltaPack> {
2161    let selection = repack_object_list(
2162        odb,
2163        roots,
2164        shallow,
2165        keep_unreachable,
2166        opts.use_bitmaps,
2167        promised,
2168    )?;
2169    write_staged_repack_pack(
2170        odb,
2171        pack_dir,
2172        &selection.send,
2173        &selection.name_hashes,
2174        opts,
2175        threads,
2176        cfg,
2177    )
2178}
2179
2180/// Stream a local pack holding exactly `objects` to a `tmp_repack_*` file in `pack_dir`,
2181/// with the delta search tuned by `opts` (`pack.window`, `pack.depth`) and
2182/// `pack.windowMemory` from `cfg`.
2183///
2184/// Every delta base is one of `objects`, so the pack is self-contained. `name_hashes`
2185/// pairs same-path blobs and trees in the search window. An empty `objects` writes
2186/// nothing and returns an empty pack.
2187///
2188/// # Errors
2189///
2190/// Propagates thread-option, object read, and staging-file I/O failures; the staging
2191/// file is removed when writing fails.
2192pub(crate) fn write_staged_repack_pack(
2193    odb: &Odb,
2194    pack_dir: &Path,
2195    objects: &[ObjectId],
2196    name_hashes: &HashMap<ObjectId, u32>,
2197    opts: &PackBuildOptions,
2198    threads: Option<usize>,
2199    cfg: Option<&ConfigSet>,
2200) -> Result<SerializedDeltaPack> {
2201    if objects.is_empty() {
2202        return Ok(SerializedDeltaPack {
2203            bytes: Vec::new(),
2204            index_rows: Vec::new(),
2205            staging_path: None,
2206        });
2207    }
2208    odb.clear_delta_base_cache();
2209    let max_depth = if opts.delta { opts.max_depth } else { 0 };
2210    let settings = RepackPackSettings {
2211        window: opts.window,
2212        max_depth,
2213        reuse_deltas: opts.reuse_deltas,
2214        reuse_objects: opts.reuse_objects,
2215        use_ofs_delta: opts.use_ofs_delta,
2216        window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
2217        search_threads: pack_delta_search_parallel_options(odb, threads)?
2218            .threads
2219            .get(),
2220        write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
2221    };
2222    let git_dir = odb
2223        .config_git_dir()
2224        .map(Path::to_path_buf)
2225        .unwrap_or_else(|| {
2226            pack_dir
2227                .parent()
2228                .and_then(|objects| objects.parent())
2229                .map(Path::to_path_buf)
2230                .unwrap_or_else(|| pack_dir.to_path_buf())
2231        });
2232    crate::git_dir::io_error::io_with_git_path(
2233        &git_dir,
2234        pack_dir,
2235        crate::git_dir::GitDirPathOperation::GcPack,
2236        fs::create_dir_all(pack_dir),
2237    )?;
2238    let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
2239    let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
2240    match written {
2241        Ok(index_rows) => Ok(SerializedDeltaPack {
2242            bytes: Vec::new(),
2243            index_rows,
2244            staging_path: Some(staging),
2245        }),
2246        Err(err) => {
2247            let _ = fs::remove_file(&staging);
2248            Err(err)
2249        }
2250    }
2251}
2252
2253/// Objects a full repack writes: everything reachable from `roots` down to the
2254/// `shallow` boundary commits, plus every other packed and loose object when
2255/// `keep_unreachable` is set.
2256fn repack_object_list(
2257    odb: &Odb,
2258    roots: &[ObjectId],
2259    shallow: &HashSet<ObjectId>,
2260    keep_unreachable: bool,
2261    use_bitmaps: bool,
2262    promised: &HashSet<ObjectId>,
2263) -> Result<PackObjectSelection> {
2264    // Roots arrive in hash-set order; sorting them makes the pack (and its name)
2265    // the same on every run.
2266    let mut roots: Vec<_> = roots
2267        .iter()
2268        .copied()
2269        .filter(|oid| !promised.contains(oid))
2270        .collect();
2271    roots.sort_unstable();
2272    let alternates = AlternateSources::load(odb)?;
2273    let use_bitmaps = use_bitmaps && promised.is_empty() && !alternates.in_use();
2274    let mut selection = enumerate_pack_objects_naming(
2275        odb,
2276        &roots,
2277        &[],
2278        &PackEnumerateOptions {
2279            filter: None,
2280            shallow_grafts: shallow,
2281            have_shallow_grafts: Some(shallow),
2282            use_bitmaps,
2283            exclude_objects: Some(promised),
2284            peer_odb: None,
2285            have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
2286        },
2287        NameTrees::Yes,
2288        true,
2289        &crate::progress::CancelSignal::never(),
2290    )?;
2291    if keep_unreachable {
2292        append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
2293    }
2294    Ok(selection)
2295}
2296
2297/// Append packed and loose objects not already selected for the repack send list.
2298fn append_repack_unreachable_retention(
2299    odb: &Odb,
2300    send: &mut Vec<ObjectId>,
2301    promised: &HashSet<ObjectId>,
2302) -> Result<()> {
2303    let alternates = AlternateSources::load(odb)?;
2304    let mut in_send: OidHashSet = send.iter().copied().collect();
2305    odb.primary()?
2306        .packed_objects()
2307        .for_each_removable_pack_object(&mut |oid| {
2308            if !promised.contains(oid) && !alternates.contains(oid) && in_send.insert(*oid) {
2309                send.push(*oid);
2310            }
2311            std::ops::ControlFlow::Continue(())
2312        })?;
2313    for (oid, _) in odb.enumerate_local_loose_objects()? {
2314        if !promised.contains(&oid) && !alternates.contains(&oid) && in_send.insert(oid) {
2315            send.push(oid);
2316        }
2317    }
2318    Ok(())
2319}
2320
2321fn pack_repack_inventory(pack_dir: &Path) -> crate::odb::RepackPackInventorySnapshot {
2322    let pack_paths = local_pack_paths(pack_dir);
2323    let kept_at_inventory = pack_paths
2324        .iter()
2325        .filter(|pack_path| pack_has_keep_marker(pack_dir, pack_path))
2326        .cloned()
2327        .collect();
2328    crate::odb::RepackPackInventorySnapshot {
2329        pack_paths,
2330        kept_at_inventory,
2331    }
2332}
2333
2334/// Every local `.pack` path under `pack_dir` at inventory time.
2335fn local_pack_paths(pack_dir: &Path) -> HashSet<PathBuf> {
2336    let Ok(entries) = fs::read_dir(pack_dir) else {
2337        return HashSet::new();
2338    };
2339    entries
2340        .filter_map(|e| e.ok())
2341        .map(|e| pack_dir.join(e.file_name()))
2342        .filter(|p| p.extension().is_some_and(|e| e == "pack"))
2343        .collect()
2344}
2345
2346/// Packs in `pack_dir` without a `.keep` marker, from a single directory read.
2347fn count_pack_files(pack_dir: &Path) -> usize {
2348    let Ok(entries) = fs::read_dir(pack_dir) else {
2349        return 0;
2350    };
2351    let mut packs = HashSet::new();
2352    let mut kept = HashSet::new();
2353    for entry in entries.filter_map(|e| e.ok()) {
2354        let path = PathBuf::from(entry.file_name());
2355        let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
2356            continue;
2357        };
2358        if ext == "pack" {
2359            packs.insert(stem.to_owned());
2360        } else if ext == "keep" {
2361            kept.insert(stem.to_owned());
2362        }
2363    }
2364    packs.difference(&kept).count()
2365}
2366
2367/// Promises may be stored outside marked packs. Keep their source ordinary packs
2368/// when no retained promisor pack supplies a copy, so repacking cannot turn an
2369/// available promise into a missing one. Only indexes are read; no object fetches.
2370fn packs_holding_available_promises(
2371    odb: &Odb,
2372    promised: &HashSet<ObjectId>,
2373) -> Result<HashSet<PathBuf>> {
2374    if promised.is_empty() {
2375        return Ok(HashSet::new());
2376    }
2377    let indexes = odb.local_pack_indexes()?;
2378    let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
2379    let mut retained = HashSet::new();
2380    for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
2381        for entry in idx.iter() {
2382            let oid = ObjectId::from_bytes(entry.oid())?;
2383            if promised.contains(&oid)
2384                && !promisor_indexes
2385                    .iter()
2386                    .any(|idx| idx.find_position(&oid).is_some())
2387            {
2388                retained.insert(idx.pack_path.clone());
2389                break;
2390            }
2391        }
2392    }
2393    Ok(retained)
2394}
2395
2396/// Remove pack artifacts in `pack_dir` except `keep_pack`, `.keep` packs, promisor packs, and
2397/// `retained_packs` (including every sidecar of those retained packs).
2398fn remove_superseded_pack_files_retaining(
2399    pack_dir: &Path,
2400    keep_pack: &Path,
2401    retained_packs: &HashSet<PathBuf>,
2402    inventory: &crate::odb::RepackPackInventorySnapshot,
2403) -> Result<usize> {
2404    let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
2405    let keep_stem = keep_pack
2406        .file_stem()
2407        .and_then(|s| s.to_str())
2408        .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
2409    if !pack_dir.is_dir() {
2410        return Ok(0);
2411    }
2412    let mut removed = 0usize;
2413    for entry in crate::git_dir::io_error::io_with_git_path(
2414        &git_dir,
2415        pack_dir,
2416        crate::git_dir::GitDirPathOperation::GcPack,
2417        fs::read_dir(pack_dir),
2418    )? {
2419        let entry = entry.map_err(|source| {
2420            crate::git_dir::io_error::map_git_dir_io(
2421                &git_dir,
2422                pack_dir,
2423                crate::git_dir::GitDirPathOperation::GcPack,
2424                source,
2425            )
2426        })?;
2427        let name = entry.file_name();
2428        let name_str = name.to_string_lossy();
2429        if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2430            continue;
2431        }
2432        if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2433            continue;
2434        }
2435        let path = entry.path();
2436        if path.is_file() {
2437            let pack_path = path.with_extension("pack");
2438            if inventory.kept_at_inventory.contains(&pack_path)
2439                || path.with_extension("promisor").is_file()
2440                || retained_packs.contains(&pack_path)
2441                || !inventory.pack_paths.contains(&pack_path)
2442            {
2443                continue;
2444            }
2445            crate::git_dir::io_error::io_with_git_path(
2446                &git_dir,
2447                &path,
2448                crate::git_dir::GitDirPathOperation::GcPack,
2449                fs::remove_file(&path),
2450            )?;
2451            removed += 1;
2452        }
2453    }
2454    Ok(removed)
2455}
2456
2457/// What [`rewrite_local_objects_without`] did.
2458#[derive(Debug, Clone, Default, PartialEq, Eq)]
2459pub(crate) struct PromisorRewrite {
2460    /// The promisor pack that now holds the kept objects; `None` when nothing was
2461    /// rewritten.
2462    pub(crate) pack_path: Option<PathBuf>,
2463    /// Objects written into that pack.
2464    pub(crate) objects: usize,
2465    /// Objects of the drop set that were stored (packed or loose) and are now gone.
2466    pub(crate) dropped: usize,
2467    /// Pack files (with their sidecars) removed.
2468    pub(crate) removed_pack_files: usize,
2469}
2470
2471/// When [`rewrite_local_objects_without`] rewrites.
2472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2473pub(crate) enum PromisorRewriteWhen {
2474    /// Only when it drops something.
2475    Dropping,
2476    /// Also to fold every local pack and loose object into one promisor pack.
2477    Always,
2478}
2479
2480/// Rewrite the repository's own packs and loose objects into one promisor pack that
2481/// leaves out `omit`.
2482///
2483/// Every object in the local packs without a `.keep` marker, promisor or not, and
2484/// every loose object goes into the new pack, except those in `omit`; there is no
2485/// reachability walk, so unreachable objects are kept and nothing else can be lost.
2486/// The new pack is marked `.promisor`, which makes the dropped objects promised as
2487/// long as an object in it refers to them (a tree naming a dropped blob, say).
2488/// The kept blobs go into the same pack, though the promisor cannot supply them:
2489/// Git counts every object a promisor-pack tree names as promised, and its repack
2490/// and `gc` keep only those promised objects that sit in a promisor pack, so a
2491/// kept blob in an ordinary pack beside it would be deleted. Maintenance that
2492/// drops promised blobs must therefore check the promisor holds each one (see
2493/// [`drop_promised_blobs`]). `.keep` packs and alternates are left alone, so
2494/// objects stored there stay. That is not enough for an object only an ordinary
2495/// (not `.promisor`) `.keep` pack holds when the new pack names it: Git then
2496/// counts it as promised and deletes it once the marker goes, and leaves a copy
2497/// out of its own promisor repack while the marker stays. Callers must rule that
2498/// out first (offload refuses with [`crate::offload::OffloadError::KeptPackObjectPromised`]).
2499///
2500/// The new pack is installed before anything is removed, and only the packs listed
2501/// when the rewrite started are removed afterwards: a pack that arrives meanwhile
2502/// (a push) survives. Then loose copies of the dropped objects and loose objects
2503/// now packed are deleted. The multi-pack-index is removed; no bitmap is written.
2504///
2505/// The new pack, its index and markers are flushed to disk before any old pack is
2506/// removed, so a crash never leaves an object in neither.
2507///
2508/// Callers hold the [`GcLock`]. With [`PromisorRewriteWhen::Dropping`], an `omit`
2509/// that names nothing stored in the rewritten packs or loose changes nothing. With
2510/// [`PromisorRewriteWhen::Always`] the rewrite also runs to fold other packs and
2511/// loose objects into one promisor pack, and is skipped only when the local
2512/// objects already are exactly one promisor pack (plus `.keep` packs).
2513///
2514/// # Errors
2515///
2516/// [`Error::UnsupportedObjectStore`] without a files-backed object store, and pack
2517/// read, write and file removal failures.
2518pub(crate) fn rewrite_local_objects_without(
2519    repo: &Repository,
2520    omit: &OidHashSet,
2521    opts: &RepackOptions,
2522    when: PromisorRewriteWhen,
2523) -> Result<PromisorRewrite> {
2524    let odb = repo.odb();
2525    odb.require_files_primary("promisor rewrite")?;
2526    if omit.is_empty() && when == PromisorRewriteWhen::Dropping {
2527        return Ok(PromisorRewrite::default());
2528    }
2529    let objects_dir = odb.objects_dir().to_path_buf();
2530    let pack_dir = objects_dir.join("pack");
2531    odb.reprepare_local_packs();
2532    let snapshot: Vec<_> = odb
2533        .local_pack_indexes()?
2534        .into_iter()
2535        .filter(|idx| !pack_has_keep_marker(&pack_dir, &idx.pack_path))
2536        .collect();
2537
2538    let mut send = Vec::new();
2539    let mut seen: OidHashSet = OidHashSet::default();
2540    let mut dropped: OidHashSet = OidHashSet::default();
2541    let mut take = |oid: ObjectId| {
2542        if omit.contains(&oid) {
2543            dropped.insert(oid);
2544        } else if seen.insert(oid) {
2545            send.push(oid);
2546        }
2547    };
2548    for idx in &snapshot {
2549        for entry in idx.iter() {
2550            take(ObjectId::from_bytes(entry.oid())?);
2551        }
2552    }
2553    let loose = odb.enumerate_local_loose_objects()?;
2554    for (oid, _) in &loose {
2555        take(*oid);
2556    }
2557    let folded = loose.is_empty() && snapshot.len() <= 1 && snapshot.iter().all(|i| i.is_promisor);
2558    let nothing_to_do = match when {
2559        PromisorRewriteWhen::Dropping => dropped.is_empty(),
2560        PromisorRewriteWhen::Always => dropped.is_empty() && folded,
2561    };
2562    if nothing_to_do {
2563        return Ok(PromisorRewrite::default());
2564    }
2565
2566    let cfg = opts
2567        .merged_config
2568        .clone()
2569        .or_else(|| repo.config().ok().map(|c| (*c).clone()));
2570    let pack_opts = opts
2571        .pack_build
2572        .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
2573    let built = write_staged_repack_pack(
2574        odb,
2575        &pack_dir,
2576        &send,
2577        &HashMap::new(),
2578        &pack_opts,
2579        opts.threads,
2580        cfg.as_ref(),
2581    )?;
2582    clear_pack_midx_state(&pack_dir)?;
2583    let mut rewrite = PromisorRewrite {
2584        dropped: dropped.len(),
2585        ..PromisorRewrite::default()
2586    };
2587    if let Some(staging) = built.staging_path.as_ref() {
2588        let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
2589        let ingested = install_pack_path(
2590            staging,
2591            odb,
2592            &IngestPackOptions {
2593                threads: opts.threads,
2594                merged_config: cfg.clone(),
2595                environment: opts.environment.clone(),
2596                skip_post_index_verify: trusted_rows.is_some(),
2597                trusted_index_rows: trusted_rows,
2598                mark_promisor_pack: true,
2599                ..IngestPackOptions::default()
2600            },
2601        )?;
2602        rewrite.objects = ingested.object_ids.len();
2603        if let Some(pack_path) = &ingested.pack_path {
2604            if cfg
2605                .as_ref()
2606                .is_none_or(|c| c.pack_write_reverse_index_default())
2607            {
2608                write_pack_rev_sidecar_for_index_path(&pack_path.with_extension("idx"))?;
2609            }
2610            crate::index_pack::sync_installed_pack(pack_path)?;
2611        }
2612        rewrite.pack_path = ingested.pack_path;
2613    }
2614
2615    // Drop cached pack mmaps before deleting pack files (Windows).
2616    odb.invalidate_packs();
2617    let new_stem = rewrite
2618        .pack_path
2619        .as_deref()
2620        .and_then(Path::file_stem)
2621        .map(|s| s.to_os_string());
2622    let stems: HashSet<std::ffi::OsString> = snapshot
2623        .iter()
2624        .filter_map(|idx| idx.pack_path.file_stem().map(|s| s.to_os_string()))
2625        .filter(|stem| Some(stem) != new_stem.as_ref())
2626        .collect();
2627    drop(snapshot);
2628    rewrite.removed_pack_files = remove_pack_files_with_stems(&pack_dir, &stems)?;
2629    for oid in &dropped {
2630        odb.remove_local_loose(oid)?;
2631    }
2632    prune_packed_objects(&objects_dir, PrunePackedOptions::default())?;
2633    odb.invalidate_packs();
2634    Ok(rewrite)
2635}
2636
2637/// Remove every file in `pack_dir` named `<stem>.<ext>` for one of `stems` (the
2638/// pack, its index and every sidecar). A pack that has gained a `.keep` marker since
2639/// it was listed is left whole. Returns how many files went.
2640fn remove_pack_files_with_stems(
2641    pack_dir: &Path,
2642    stems: &HashSet<std::ffi::OsString>,
2643) -> Result<usize> {
2644    if stems.is_empty() {
2645        return Ok(0);
2646    }
2647    let mut removed = 0usize;
2648    for entry in fs::read_dir(pack_dir)?.filter_map(|e| e.ok()) {
2649        let path = entry.path();
2650        let Some(stem) = path.file_stem().filter(|stem| stems.contains(*stem)) else {
2651            continue;
2652        };
2653        if !path.is_file() || pack_dir.join(stem).with_extension("keep").is_file() {
2654            continue;
2655        }
2656        fs::remove_file(&path)?;
2657        removed += 1;
2658    }
2659    Ok(removed)
2660}
2661
2662fn pack_has_keep_marker(pack_dir: &Path, pack_path: &Path) -> bool {
2663    let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
2664        return false;
2665    };
2666    pack_dir.join(format!("{stem}.keep")).is_file()
2667}
2668
2669/// Pack loose refs into `packed-refs` (files backend) or compact reftable stacks.
2670///
2671/// With the files backend, directories emptied by removing packed loose refs are
2672/// deleted, but `refs/` itself is kept so Git still recognizes the repository.
2673///
2674/// # Errors
2675///
2676/// Propagates ref-store, locking, and I/O failures.
2677pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2678    let (path, refs_packed, refs_pruned) =
2679        crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2680    Ok(PackRefsReport {
2681        path,
2682        refs_packed,
2683        refs_pruned,
2684    })
2685}
2686
2687impl Repository {
2688    /// Write a reachable commit-graph file for this repository.
2689    ///
2690    /// # Errors
2691    ///
2692    /// See [`write_commit_graph`].
2693    pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2694        write_commit_graph(self, opts)
2695    }
2696
2697    /// Build a reachable commit-graph in memory, for any object store.
2698    ///
2699    /// # Errors
2700    ///
2701    /// See [`build_commit_graph`].
2702    pub fn build_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphBuild> {
2703        build_commit_graph(self, opts)
2704    }
2705
2706    /// Write or refresh the multi-pack index for this repository's pack directory.
2707    ///
2708    /// # Errors
2709    ///
2710    /// See [`write_midx`].
2711    pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2712        write_midx(self, opts)
2713    }
2714}
2715
2716/// Standalone object fsck helpers (single-object checks).
2717pub mod fsck {
2718    pub use crate::fsck_msg_policy::{
2719        FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2720    };
2721    pub use crate::fsck_standalone::{fsck_object, fsck_object_with, FsckError, FsckObjectOptions};
2722}
2723
2724/// Repository-wide fsck (object store, connectivity, refs).
2725pub mod repository_fsck {
2726    pub use crate::fsck::{
2727        format_fsck_line, fsck, issue_msg_id, issue_severity, issue_visible_by_default,
2728        reachability_roots, FsckDiagnostic, FsckIssue, FsckOptions, FsckReport, FsckSeverity,
2729        FsckSummary, MissingObjectReferrer,
2730    };
2731}
2732
2733#[cfg(test)]
2734pub(crate) mod gc_test_hooks {
2735    use std::cell::RefCell;
2736
2737    /// Where in [`super::repack_detail`] a test hook interleaves another writer.
2738    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
2739    pub enum GcPausePoint {
2740        /// After pack inventory is recorded and before maintenance roots are collected.
2741        AfterInventoryBeforeRoots,
2742        /// After maintenance roots are collected and before superseded packs are dropped.
2743        AfterRootsBeforeDropAll,
2744    }
2745
2746    type GcHook = (GcPausePoint, Box<dyn FnOnce() + Send>);
2747
2748    thread_local! {
2749        static GC_HOOK: RefCell<Option<GcHook>> = const { RefCell::new(None) };
2750    }
2751
2752    /// Run `hook` during the next repack on this thread when it reaches `point`.
2753    pub fn on_next_gc(point: GcPausePoint, hook: impl FnOnce() + Send + 'static) {
2754        GC_HOOK.with(|slot| *slot.borrow_mut() = Some((point, Box::new(hook))));
2755    }
2756
2757    pub(crate) fn run(point: GcPausePoint) {
2758        let hook = GC_HOOK.with(|slot| {
2759            let mut slot = slot.borrow_mut();
2760            match slot.as_ref() {
2761                Some((at, _)) if *at == point => slot.take().map(|(_, hook)| hook),
2762                _ => None,
2763            }
2764        });
2765        if let Some(hook) = hook {
2766            hook();
2767        }
2768    }
2769}
2770
2771#[cfg(test)]
2772mod tests {
2773    #![allow(clippy::unwrap_used, clippy::expect_used)]
2774
2775    use super::*;
2776    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2777    use crate::index_pack::install_pack_bytes;
2778    use crate::porcelain::add::stage_for_commit;
2779    use crate::porcelain::commit::{create_commit, CommitRequest};
2780    use crate::progress::{NeverCancel, NullProgress};
2781    use crate::repo::InitOptions;
2782    use filetime::FileTime;
2783    use std::time::SystemTime;
2784    use tempfile::TempDir;
2785
2786    /// Pack `oids` (already stored) into one new pack of `repo`, with a `.keep` marker
2787    /// when `keep`; returns the pack path.
2788    fn pack_into(repo: &Repository, oids: &[ObjectId], keep: bool) -> PathBuf {
2789        let pack = crate::pack_objects::build_pack_from_send_list(
2790            repo.odb(),
2791            oids,
2792            &HashSet::new(),
2793            &PackBuildOptions::default(),
2794            &HashMap::new(),
2795        )
2796        .unwrap();
2797        install_pack_bytes(
2798            pack,
2799            repo.odb(),
2800            &IngestPackOptions {
2801                keep_message: keep.then(|| "pinned".to_owned()),
2802                ..IngestPackOptions::default()
2803            },
2804        )
2805        .unwrap()
2806        .pack_path
2807        .unwrap()
2808    }
2809
2810    #[test]
2811    fn promisor_rewrite_drops_only_the_omitted_objects() {
2812        let dir = TempDir::new().unwrap();
2813        let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2814        let odb = repo.odb();
2815        let big = odb.write(ObjectKind::Blob, b"big blob").unwrap();
2816        let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2817        let kept = odb.write(ObjectKind::Blob, b"kept").unwrap();
2818        let unreachable = odb.write(ObjectKind::Blob, b"unreachable loose").unwrap();
2819        let mut body = b"100644 big\0".to_vec();
2820        body.extend_from_slice(big.as_bytes());
2821        let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2822        let old_pack = pack_into(&repo, &[packed, big, tree], false);
2823        let kept_pack = pack_into(&repo, &[kept], true);
2824        crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2825        // A loose copy of an omitted object goes too.
2826        odb.write_loose_materialize(ObjectKind::Blob, b"big blob")
2827            .unwrap();
2828
2829        let omit: OidHashSet = [big, kept].into_iter().collect();
2830        let rewrite = rewrite_local_objects_without(
2831            &repo,
2832            &omit,
2833            &RepackOptions::default(),
2834            PromisorRewriteWhen::Dropping,
2835        )
2836        .unwrap();
2837        assert_eq!(rewrite.dropped, 1, "the kept copy is not ours to drop");
2838        assert_eq!(rewrite.objects, 3, "tree + packed + unreachable");
2839        let new_pack = rewrite.pack_path.expect("new pack");
2840        assert!(new_pack.with_extension("promisor").is_file());
2841        // Kept blobs share the promisor pack with the tree, so Git's repack keeps
2842        // them (it keeps only the promised objects found in promisor packs).
2843        repo.odb().reprepare_local_packs();
2844        let indexes = repo.odb().local_pack_indexes().unwrap();
2845        let new_idx = indexes
2846            .iter()
2847            .find(|i| i.pack_path == new_pack)
2848            .expect("listed");
2849        for oid in [tree, packed, unreachable] {
2850            assert!(new_idx.find_position(&oid).is_some(), "{oid}");
2851        }
2852        assert!(!old_pack.exists());
2853        assert!(kept_pack.is_file() && kept_pack.with_extension("keep").is_file());
2854        assert!(rewrite.removed_pack_files >= 2, "old .pack and .idx");
2855
2856        let repo =
2857            Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2858        let odb = repo.odb();
2859        assert!(!odb.exists_stored(&big));
2860        assert!(odb.local_loose_file_size(&big).is_none());
2861        for oid in [packed, kept, unreachable, tree] {
2862            assert!(odb.exists_stored(&oid), "{oid} kept");
2863        }
2864        assert!(odb.enumerate_local_loose_objects().unwrap().is_empty());
2865    }
2866
2867    #[test]
2868    fn promisor_rewrite_without_anything_to_drop_changes_nothing() {
2869        let dir = TempDir::new().unwrap();
2870        let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2871        let blob = repo.odb().write(ObjectKind::Blob, b"x").unwrap();
2872        let absent = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
2873        for omit in [OidHashSet::default(), [absent].into_iter().collect()] {
2874            let rewrite = rewrite_local_objects_without(
2875                &repo,
2876                &omit,
2877                &RepackOptions::default(),
2878                PromisorRewriteWhen::Dropping,
2879            )
2880            .unwrap();
2881            assert_eq!(rewrite, PromisorRewrite::default());
2882        }
2883        assert!(repo.odb().local_loose_file_size(&blob).is_some());
2884    }
2885
2886    #[test]
2887    fn promisor_rewrite_always_folds_until_one_promisor_pack_is_left() {
2888        let dir = TempDir::new().unwrap();
2889        let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2890        let odb = repo.odb();
2891        let pushed = odb.write(ObjectKind::Blob, b"pushed").unwrap();
2892        let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2893        let mut body = b"100644 packed\0".to_vec();
2894        body.extend_from_slice(packed.as_bytes());
2895        let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2896        // A promisor pack of the tree and an ordinary pack of its blob: the layout
2897        // an earlier offload wrote, which Git's repack loses the blob from.
2898        let tree_pack = pack_into(&repo, &[tree], false);
2899        fs::write(tree_pack.with_extension("promisor"), b"").unwrap();
2900        let blob_pack = pack_into(&repo, &[packed], false);
2901        crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2902        odb.write_loose_materialize(ObjectKind::Blob, b"pushed")
2903            .unwrap();
2904        let fold = |repo: &Repository| {
2905            rewrite_local_objects_without(
2906                repo,
2907                &OidHashSet::default(),
2908                &RepackOptions::default(),
2909                PromisorRewriteWhen::Always,
2910            )
2911            .unwrap()
2912        };
2913        let first = fold(&repo);
2914        assert_eq!(first.dropped, 0);
2915        assert_eq!(first.objects, 3);
2916        let pack = first.pack_path.expect("folded pack");
2917        assert!(pack.with_extension("promisor").is_file());
2918        assert!(!tree_pack.exists() && !blob_pack.exists());
2919        let repo =
2920            Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2921        assert!(repo
2922            .odb()
2923            .enumerate_local_loose_objects()
2924            .unwrap()
2925            .is_empty());
2926        let indexes = repo.odb().local_pack_indexes().unwrap();
2927        assert_eq!(indexes.len(), 1);
2928        for oid in [pushed, packed, tree] {
2929            assert!(indexes[0].find_position(&oid).is_some(), "{oid}");
2930        }
2931        // One promisor pack and nothing loose: nothing left to fold.
2932        assert_eq!(fold(&repo), PromisorRewrite::default());
2933        assert!(pack.is_file());
2934    }
2935
2936    #[test]
2937    fn pack_removal_spares_unlisted_and_newly_kept_packs() {
2938        let dir = TempDir::new().unwrap();
2939        for name in [
2940            "pack-a.pack",
2941            "pack-a.idx",
2942            "pack-a.rev",
2943            "pack-b.pack",
2944            "pack-b.idx",
2945            "pack-c.pack",
2946            "pack-c.keep",
2947        ] {
2948            fs::write(dir.path().join(name), b"").unwrap();
2949        }
2950        let stems: HashSet<std::ffi::OsString> =
2951            ["pack-a", "pack-c"].into_iter().map(Into::into).collect();
2952        assert_eq!(remove_pack_files_with_stems(dir.path(), &stems).unwrap(), 3);
2953        let mut left: Vec<String> = fs::read_dir(dir.path())
2954            .unwrap()
2955            .flatten()
2956            .map(|e| e.file_name().to_string_lossy().into_owned())
2957            .collect();
2958        left.sort();
2959        assert_eq!(
2960            left,
2961            ["pack-b.idx", "pack-b.pack", "pack-c.keep", "pack-c.pack"]
2962        );
2963    }
2964
2965    #[cfg_attr(
2966        not(feature = "full-tests"),
2967        ignore = "starts system git or is heavy; run with --features full-tests"
2968    )]
2969    #[test]
2970    fn gc_auto_skip_single_pack_without_loose() {
2971        let dir = TempDir::new().unwrap();
2972        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2973        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2974        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2975        let ident = "Test <t@example.com> 1 +0000".to_string();
2976        create_commit(
2977            &repo,
2978            &CommitRequest {
2979                message: "init".into(),
2980                author: Some(ident.parse().expect("signature")),
2981                committer: Some(ident.parse().expect("signature")),
2982                allow_empty: false,
2983                sign_override: None,
2984                amend: false,
2985                assume_worktree_synced: false,
2986                skip_pre_commit_tree_match: false,
2987                staged_change_count: None,
2988                ..Default::default()
2989            },
2990            &mut NullProgress,
2991            &NeverCancel,
2992        )
2993        .expect("commit");
2994        git_cmd(dir.path(), &["repack", "-a", "-d"]);
2995        assert!(should_auto_skip_repack(0, 1, false, true, None));
2996        assert!(!should_auto_skip_repack(3, 1, false, true, None));
2997        assert!(!should_auto_skip_repack(0, 2, false, true, None));
2998        assert!(!should_auto_skip_repack(0, 2, false, false, None));
2999    }
3000
3001    #[test]
3002    fn count_pack_files_ignores_keep_markers() {
3003        let dir = TempDir::new().unwrap();
3004        let pack_dir = dir.path().join("pack");
3005        fs::create_dir_all(&pack_dir).unwrap();
3006        fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
3007        fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
3008        fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
3009        assert_eq!(count_pack_files(&pack_dir), 1);
3010    }
3011
3012    fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
3013        let dir = TempDir::new().unwrap();
3014        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3015        let config_path = repo.git_dir().join("config");
3016        let mut config = fs::read_to_string(&config_path).unwrap();
3017        config.push_str(snippet);
3018        fs::write(&config_path, config).unwrap();
3019        repo.reload_config().expect("reload config");
3020        (dir, repo)
3021    }
3022
3023    fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
3024        let dir = repo.git_dir().join("objects").join(fanout);
3025        fs::create_dir_all(&dir).unwrap();
3026        for name in names {
3027            fs::write(dir.join(name), b"").unwrap();
3028        }
3029    }
3030
3031    const NAME_A: &str = "00000000000000000000000000000000000001";
3032    const NAME_B: &str = "00000000000000000000000000000000000002";
3033    const NAME_C: &str = "00000000000000000000000000000000000003";
3034
3035    #[test]
3036    fn auto_gc_needed_samples_one_fanout_directory() {
3037        // gc.auto = 300 allows ceil(300 / 256) = 2 sampled objects.
3038        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
3039        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3040        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3041        fake_loose(&repo, "18", &[NAME_C]);
3042        fake_loose(
3043            &repo,
3044            "17",
3045            &["tmp_obj_123", "0000000000000000000000000000000000000z"],
3046        );
3047        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3048        fake_loose(&repo, "17", &[NAME_C]);
3049        assert_eq!(
3050            auto_gc_needed(&repo).unwrap(),
3051            Some(AutoGcReason::TooManyLoose {
3052                estimate: 3 * 256,
3053                limit: 300
3054            })
3055        );
3056    }
3057
3058    #[test]
3059    fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
3060        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
3061        fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
3062        let pack_dir = repo.git_dir().join("objects/pack");
3063        fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
3064        fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
3065        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3066        assert_eq!(
3067            gc_auto(&repo, &GcOptions::default()).unwrap(),
3068            AutoGcOutcome::NotNeeded
3069        );
3070    }
3071
3072    #[test]
3073    fn auto_gc_needed_counts_packs_without_keep_markers() {
3074        let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
3075        let pack_dir = repo.git_dir().join("objects/pack");
3076        for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
3077            fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
3078        }
3079        assert_eq!(
3080            auto_gc_needed(&repo).unwrap(),
3081            Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
3082        );
3083        fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
3084        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3085    }
3086
3087    fn config_snippet(text: &str) -> ConfigSet {
3088        let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
3089        let mut set = ConfigSet::new();
3090        set.merge(&file);
3091        set
3092    }
3093
3094    #[test]
3095    fn prune_expire_now_and_all_have_no_grace_window() {
3096        let now = 1_700_000_000;
3097        for spec in ["now", "all", "NOW"] {
3098            let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3099            assert!(matches!(
3100                gc_loose_prune_policy(&cfg, now).unwrap(),
3101                GcLoosePrunePolicy::Enabled {
3102                    keep_newer_than: None
3103                }
3104            ));
3105        }
3106        for spec in ["never", "false"] {
3107            let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3108            assert!(matches!(
3109                gc_loose_prune_policy(&cfg, now).unwrap(),
3110                GcLoosePrunePolicy::Disabled
3111            ));
3112        }
3113        let cfg = ConfigSet::new();
3114        assert!(matches!(
3115            gc_loose_prune_policy(&cfg, now).unwrap(),
3116            GcLoosePrunePolicy::Enabled {
3117                keep_newer_than: Some(_)
3118            }
3119        ));
3120    }
3121
3122    #[test]
3123    fn gc_lock_is_exclusive_and_released_on_drop() {
3124        let (_dir, repo) = repo_with_config("");
3125        let now = OffsetDateTime::now_utc().unix_timestamp();
3126        let pid_path = repo.git_dir().join("gc.pid");
3127        let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
3128            panic!("first acquire must succeed");
3129        };
3130        let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
3131        assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
3132        assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
3133        match GcLock::try_acquire(&repo, now).unwrap() {
3134            GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
3135            GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
3136        }
3137        assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
3138        assert!(matches!(
3139            gc(&repo, &GcOptions::default()),
3140            Err(Error::MaintenanceRunning { .. })
3141        ));
3142        assert!(!repo.git_dir().join("gc.pid.lock").exists());
3143        drop(lock);
3144        assert!(!pid_path.exists());
3145        assert_eq!(GcLock::current_holder(&repo, now), None);
3146        assert!(matches!(
3147            GcLock::try_acquire(&repo, now).unwrap(),
3148            GcLockAttempt::Acquired(_)
3149        ));
3150    }
3151
3152    #[test]
3153    fn gc_lock_takes_over_stale_entries() {
3154        let (_dir, repo) = repo_with_config("");
3155        let now = OffsetDateTime::now_utc().unix_timestamp();
3156        let pid_path = repo.git_dir().join("gc.pid");
3157        let host = local_host_name();
3158
3159        // A process on this host that is no longer running.
3160        fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
3161        assert!(matches!(
3162            GcLock::try_acquire(&repo, now).unwrap(),
3163            GcLockAttempt::Acquired(_)
3164        ));
3165
3166        // Another host cannot be checked, so a recent entry blocks.
3167        fs::write(&pid_path, "4242 some-other-host").unwrap();
3168        assert!(matches!(
3169            GcLock::try_acquire(&repo, now).unwrap(),
3170            GcLockAttempt::Held(GcLockHolder {
3171                pid: Some(4242),
3172                ..
3173            })
3174        ));
3175
3176        // The same entry is ignored once it is older than 12 hours.
3177        let later = now + time::Duration::hours(13).whole_seconds();
3178        assert!(matches!(
3179            GcLock::try_acquire(&repo, later).unwrap(),
3180            GcLockAttempt::Acquired(_)
3181        ));
3182    }
3183
3184    #[test]
3185    fn gc_auto_reports_held_lock_without_running() {
3186        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3187        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3188        fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
3189        let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3190        assert!(
3191            matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
3192            "{outcome:?}"
3193        );
3194        assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
3195    }
3196
3197    #[cfg(unix)]
3198    #[test]
3199    fn gc_auto_honors_failing_pre_auto_gc_hook() {
3200        use std::os::unix::fs::PermissionsExt as _;
3201        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3202        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3203        let hook = repo.git_dir().join("hooks/pre-auto-gc");
3204        fs::create_dir_all(hook.parent().unwrap()).unwrap();
3205        fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
3206        fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
3207        let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3208        assert!(
3209            matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
3210            "{outcome:?}"
3211        );
3212        assert!(!repo.git_dir().join("gc.pid").exists());
3213    }
3214
3215    fn git_cmd(dir: &Path, args: &[&str]) {
3216        let status = std::process::Command::new("git")
3217            .current_dir(dir)
3218            .args(args)
3219            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3220            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3221            .status()
3222            .expect("git");
3223        assert!(status.success(), "git {args:?}");
3224    }
3225
3226    fn assert_fsck_clean(repo: &Repository) {
3227        let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
3228        let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
3229        assert!(
3230            report.ok(&repo.config().expect("config"), &opts),
3231            "{:?}",
3232            report.issues
3233        );
3234    }
3235
3236    #[test]
3237    fn fsck_clean_repo_ok() {
3238        let dir = TempDir::new().unwrap();
3239        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3240        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3241        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3242        let ident = "Test <t@example.com> 1 +0000".to_string();
3243        create_commit(
3244            &repo,
3245            &CommitRequest {
3246                message: "init".into(),
3247                author: Some(ident.parse().expect("signature")),
3248                committer: Some(ident.parse().expect("signature")),
3249                allow_empty: false,
3250                sign_override: None,
3251                amend: false,
3252                assume_worktree_synced: false,
3253                skip_pre_commit_tree_match: false,
3254                staged_change_count: None,
3255                ..Default::default()
3256            },
3257            &mut NullProgress,
3258            &NeverCancel,
3259        )
3260        .expect("commit");
3261        assert_fsck_clean(&repo);
3262    }
3263
3264    #[test]
3265    fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
3266        let dir = TempDir::new().unwrap();
3267        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3268        let wt = repo.work_tree().unwrap();
3269        std::fs::write(wt.join("base"), b"v1").unwrap();
3270        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3271        let ident = "Test <t@example.com> 1 +0000".to_string();
3272        create_commit(
3273            &repo,
3274            &CommitRequest {
3275                message: "base".into(),
3276                author: Some(ident.parse().expect("signature")),
3277                committer: Some(ident.parse().expect("signature")),
3278                allow_empty: false,
3279                sign_override: None,
3280                amend: false,
3281                assume_worktree_synced: false,
3282                skip_pre_commit_tree_match: false,
3283                staged_change_count: None,
3284                ..Default::default()
3285            },
3286            &mut NullProgress,
3287            &NeverCancel,
3288        )
3289        .expect("commit");
3290        repack_all(
3291            &repo,
3292            &RepackOptions {
3293                keep_unreachable: false,
3294                ..RepackOptions::default()
3295            },
3296        )
3297        .expect("repack base");
3298        let pack_dir = repo.git_dir().join("objects/pack");
3299        assert_eq!(count_pack_files(&pack_dir), 1);
3300
3301        std::fs::write(wt.join("child"), b"v2").unwrap();
3302        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
3303        create_commit(
3304            &repo,
3305            &CommitRequest {
3306                message: "child".into(),
3307                author: Some(ident.parse().expect("signature")),
3308                committer: Some(ident.parse().expect("signature")),
3309                allow_empty: false,
3310                sign_override: None,
3311                amend: false,
3312                assume_worktree_synced: false,
3313                skip_pre_commit_tree_match: false,
3314                staged_change_count: None,
3315                ..Default::default()
3316            },
3317            &mut NullProgress,
3318            &NeverCancel,
3319        )
3320        .expect("child commit");
3321        assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3322
3323        let packs_before = count_pack_files(&pack_dir);
3324        repack_all(
3325            &repo,
3326            &RepackOptions {
3327                incremental: true,
3328                write_bitmap: true,
3329                ..RepackOptions::default()
3330            },
3331        )
3332        .expect("incremental repack with bitmap must not fail after installing a pack");
3333
3334        assert!(
3335            count_pack_files(&pack_dir) > packs_before,
3336            "incremental repack should add a pack"
3337        );
3338        let midx = pack_dir.join("multi-pack-index");
3339        assert!(
3340            midx.is_file(),
3341            "expected multi-pack-index after bitmap repack"
3342        );
3343        let midx_bytes = fs::read(&midx).expect("read midx");
3344        assert_eq!(
3345            midx_bytes.get(4),
3346            Some(&1),
3347            "MIDX on-disk version must be v1 for system git compatibility"
3348        );
3349        let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
3350            .unwrap()
3351            .filter_map(|e| e.ok())
3352            .map(|e| e.path())
3353            .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
3354            .collect();
3355        assert!(
3356            !bitmaps.is_empty(),
3357            "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
3358        );
3359
3360        assert_fsck_clean(&repo);
3361    }
3362
3363    #[test]
3364    fn incremental_repack_adds_pack_without_removing_base() {
3365        let dir = TempDir::new().unwrap();
3366        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3367        let wt = repo.work_tree().unwrap();
3368        std::fs::write(wt.join("base"), b"v1").unwrap();
3369        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3370        let ident = "Test <t@example.com> 1 +0000".to_string();
3371        create_commit(
3372            &repo,
3373            &CommitRequest {
3374                message: "base".into(),
3375                author: Some(ident.parse().expect("signature")),
3376                committer: Some(ident.parse().expect("signature")),
3377                allow_empty: false,
3378                sign_override: None,
3379                amend: false,
3380                assume_worktree_synced: false,
3381                skip_pre_commit_tree_match: false,
3382                staged_change_count: None,
3383                ..Default::default()
3384            },
3385            &mut NullProgress,
3386            &NeverCancel,
3387        )
3388        .expect("commit");
3389        repack_all(
3390            &repo,
3391            &RepackOptions {
3392                keep_unreachable: false,
3393                ..RepackOptions::default()
3394            },
3395        )
3396        .expect("repack");
3397        let pack_dir = repo.git_dir().join("objects/pack");
3398        let packs_before = count_pack_files(&pack_dir);
3399        assert_eq!(packs_before, 1);
3400        for i in 0..5 {
3401            create_commit(
3402                &repo,
3403                &CommitRequest {
3404                    message: format!("empty-{i}"),
3405                    author: Some(ident.parse().expect("signature")),
3406                    committer: Some(ident.parse().expect("signature")),
3407                    allow_empty: true,
3408                    sign_override: None,
3409                    amend: false,
3410                    assume_worktree_synced: false,
3411                    skip_pre_commit_tree_match: false,
3412                    staged_change_count: None,
3413                    ..Default::default()
3414                },
3415                &mut NullProgress,
3416                &NeverCancel,
3417            )
3418            .expect("empty");
3419        }
3420        assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3421        repack_all(
3422            &repo,
3423            &RepackOptions {
3424                incremental: true,
3425                write_bitmap: false,
3426                ..RepackOptions::default()
3427            },
3428        )
3429        .expect("incremental repack");
3430        assert!(
3431            count_pack_files(&pack_dir) >= 2,
3432            "incremental repack should add a pack, not replace the base"
3433        );
3434    }
3435
3436    #[test]
3437    fn gc_full_run_records_per_phase_timings() {
3438        let dir = TempDir::new().unwrap();
3439        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3440        let wt = repo.work_tree().unwrap();
3441        for i in 0..5 {
3442            std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3443            stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3444            let ident = "Test <t@example.com> 1 +0000".to_string();
3445            create_commit(
3446                &repo,
3447                &CommitRequest {
3448                    message: format!("c{i}"),
3449                    author: Some(ident.parse().expect("signature")),
3450                    committer: Some(ident.parse().expect("signature")),
3451                    allow_empty: false,
3452                    sign_override: None,
3453                    amend: false,
3454                    assume_worktree_synced: false,
3455                    skip_pre_commit_tree_match: false,
3456                    staged_change_count: None,
3457                    ..Default::default()
3458                },
3459                &mut NullProgress,
3460                &NeverCancel,
3461            )
3462            .expect("commit");
3463        }
3464        let stats = gc(
3465            &repo,
3466            &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
3467        )
3468        .expect("gc");
3469        let t = stats.phase_timings;
3470        assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
3471        assert!(
3472            t.repack_ms > 0,
3473            "repack phase must be timed separately from full gc"
3474        );
3475        assert!(stats.pack_refs.is_some());
3476        assert!(stats.commit_graph.is_some());
3477        assert_eq!(
3478            t.prune_ms, stats.repack.unreachable_prune_ms,
3479            "prune_ms must track unreachable loose prune inside repack, not repack_ms"
3480        );
3481        // Cached ref/config paths can finish pack-refs or commit-graph in under 1 ms (0 in
3482        // millisecond buckets). Require repack timing plus evidence the other phases ran.
3483        assert!(
3484            t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
3485            "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
3486        );
3487        assert!(stats.prune_ran, "full gc should run unreachable prune");
3488    }
3489
3490    #[cfg_attr(
3491        not(feature = "full-tests"),
3492        ignore = "starts system git or is heavy; run with --features full-tests"
3493    )]
3494    #[test]
3495    fn full_repack_keeps_migrated_pack_before_refs_updated() {
3496        use crate::receive_quarantine::ReceiveQuarantine;
3497
3498        let tmp = tempfile::tempdir().expect("tempdir");
3499        let remote = tmp.path().join("remote.git");
3500        let wt = tmp.path().join("wt");
3501        std::fs::create_dir_all(&wt).unwrap();
3502        std::fs::create_dir_all(&remote).unwrap();
3503        let git = |dir: &Path, args: &[&str]| {
3504            assert!(std::process::Command::new("git")
3505                .current_dir(dir)
3506                .args(args)
3507                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3508                .env("GIT_CONFIG_SYSTEM", "/dev/null")
3509                .status()
3510                .expect("git")
3511                .success());
3512        };
3513        git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3514        git(&wt, &["init", "-q", "-b", "main"]);
3515        std::fs::write(wt.join("payload.txt"), b"payload\n").unwrap();
3516        git(&wt, &["add", "payload.txt"]);
3517        git(&wt, &["commit", "-qm", "base"]);
3518        git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3519        git(&remote, &["repack", "-a", "-d"]);
3520        let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3521            .expect("open remote");
3522        let pack_dir = repo.git_dir().join("objects/pack");
3523        let base_pack = super::local_pack_paths(&pack_dir)
3524            .into_iter()
3525            .next()
3526            .expect("base pack");
3527        let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3528        let q_root = PathBuf::from(
3529            q.hook_env()
3530                .into_iter()
3531                .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3532                .map(|(_, v)| v)
3533                .expect("quarantine path"),
3534        );
3535        let q_pack_dir = q_root.join("pack");
3536        fs::create_dir_all(&q_pack_dir).unwrap();
3537        for ext in ["pack", "idx", "rev"] {
3538            let src = base_pack.with_extension(ext);
3539            if src.is_file() {
3540                fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3541            }
3542        }
3543        let guards = q.migrate().expect("migrate");
3544        assert!(
3545            super::local_pack_paths(&pack_dir).len() >= 2,
3546            "migrated push pack must be visible before gc inventories packs"
3547        );
3548        repack_all(
3549            &repo,
3550            &RepackOptions {
3551                auto_skip: false,
3552                write_bitmap: false,
3553                ..RepackOptions::default()
3554            },
3555        )
3556        .expect("repack");
3557        assert!(
3558            pack_dir.join("pack-incoming.pack").is_file(),
3559            "incoming pack must survive full repack while .keep guards are held"
3560        );
3561        drop(guards);
3562    }
3563
3564    #[test]
3565    fn full_repack_drops_unprotected_migrated_pack_before_refs_updated() {
3566        use crate::receive_quarantine::ReceiveQuarantine;
3567
3568        let tmp = tempfile::tempdir().expect("tempdir");
3569        let remote = tmp.path().join("remote.git");
3570        let wt = tmp.path().join("wt");
3571        std::fs::create_dir_all(&wt).unwrap();
3572        std::fs::create_dir_all(&remote).unwrap();
3573        let git = |dir: &Path, args: &[&str]| {
3574            assert!(std::process::Command::new("git")
3575                .current_dir(dir)
3576                .args(args)
3577                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3578                .env("GIT_CONFIG_SYSTEM", "/dev/null")
3579                .status()
3580                .expect("git")
3581                .success());
3582        };
3583        git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3584        git(&wt, &["init", "-q", "-b", "main"]);
3585        git(&wt, &["commit", "--allow-empty", "-qm", "base"]);
3586        git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3587        git(&remote, &["repack", "-a", "-d"]);
3588        let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3589            .expect("open");
3590        let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3591        let orphan = q
3592            .odb()
3593            .write(crate::objects::ObjectKind::Blob, b"orphan-bytes")
3594            .expect("write in quarantine");
3595        drop(q.migrate().expect("migrate"));
3596        repack_all(
3597            &repo,
3598            &RepackOptions {
3599                auto_skip: false,
3600                write_bitmap: false,
3601                keep_unreachable: false,
3602                ..RepackOptions::default()
3603            },
3604        )
3605        .expect("repack");
3606        assert!(
3607            !repo.odb().exists(&orphan),
3608            "without .keep protection gc may drop an unreachable migrated loose object"
3609        );
3610    }
3611
3612    #[cfg_attr(
3613        not(feature = "full-tests"),
3614        ignore = "starts system git or is heavy; run with --features full-tests"
3615    )]
3616    #[test]
3617    fn repack_retains_inventory_kept_pack_after_ref_publish_and_guard_drop() {
3618        use crate::receive_quarantine::ReceiveQuarantine;
3619        use std::process::Command;
3620        use std::sync::{Arc, Mutex};
3621
3622        let tmp = tempfile::tempdir().expect("tempdir");
3623        let remote = tmp.path().join("remote.git");
3624        let wt = tmp.path().join("wt");
3625        std::fs::create_dir_all(&wt).unwrap();
3626        std::fs::create_dir_all(&remote).unwrap();
3627        let git = |dir: &Path, args: &[&str]| {
3628            assert!(Command::new("git")
3629                .current_dir(dir)
3630                .args(args)
3631                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3632                .env("GIT_CONFIG_SYSTEM", "/dev/null")
3633                .status()
3634                .expect("git")
3635                .success());
3636        };
3637        git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3638        git(&wt, &["init", "-q", "-b", "main"]);
3639        std::fs::write(wt.join("payload.txt"), b"v1\n").unwrap();
3640        git(&wt, &["add", "payload.txt"]);
3641        git(&wt, &["commit", "-qm", "base"]);
3642        git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3643        git(&remote, &["repack", "-a", "-d"]);
3644        std::fs::write(wt.join("payload.txt"), b"v2\n").unwrap();
3645        git(&wt, &["add", "payload.txt"]);
3646        git(&wt, &["commit", "-qm", "incoming"]);
3647        git(&wt, &["repack", "-a", "-d"]);
3648        let incoming_hex = String::from_utf8(
3649            Command::new("git")
3650                .current_dir(&wt)
3651                .args(["rev-parse", "HEAD"])
3652                .output()
3653                .expect("rev-parse")
3654                .stdout,
3655        )
3656        .expect("utf8")
3657        .trim()
3658        .to_owned();
3659        let incoming = ObjectId::from_hex(&incoming_hex).expect("incoming tip");
3660
3661        let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3662            .expect("open remote");
3663        let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3664        let q_root = PathBuf::from(
3665            q.hook_env()
3666                .into_iter()
3667                .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3668                .map(|(_, v)| v)
3669                .expect("quarantine path"),
3670        );
3671        let q_pack_dir = q_root.join("pack");
3672        fs::create_dir_all(&q_pack_dir).unwrap();
3673        let wt_pack_dir = wt.join(".git/objects/pack");
3674        let wt_pack = fs::read_dir(&wt_pack_dir)
3675            .expect("wt pack dir")
3676            .filter_map(|e| e.ok())
3677            .map(|e| e.path())
3678            .find(|p| p.extension().is_some_and(|ext| ext == "pack"))
3679            .expect("wt repack pack");
3680        for ext in ["pack", "idx", "rev"] {
3681            let src = wt_pack.with_extension(ext);
3682            if src.is_file() {
3683                fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3684            }
3685        }
3686        assert!(
3687            q_pack_dir.join("pack-incoming.pack").is_file(),
3688            "incoming pack sidecar"
3689        );
3690
3691        let pack_dir = repo.git_dir().join("objects/pack");
3692        let incoming_keep = pack_dir.join("pack-incoming.keep");
3693        let guards_slot = Arc::new(Mutex::new(None));
3694        let guards = q.migrate().expect("migrate");
3695        *guards_slot.lock().expect("lock") = Some(guards);
3696        assert!(incoming_keep.is_file());
3697
3698        let remote_for_hook = remote.clone();
3699        let incoming_for_hook = incoming_hex.clone();
3700        let incoming_keep_for_hook = incoming_keep.clone();
3701        let guards_for_hook = Arc::clone(&guards_slot);
3702        super::gc_test_hooks::on_next_gc(
3703            super::gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll,
3704            move || {
3705                assert!(Command::new("git")
3706                    .current_dir(&remote_for_hook)
3707                    .args(["update-ref", "refs/heads/main", &incoming_for_hook,])
3708                    .env("GIT_CONFIG_GLOBAL", "/dev/null")
3709                    .env("GIT_CONFIG_SYSTEM", "/dev/null")
3710                    .status()
3711                    .expect("update-ref")
3712                    .success());
3713                drop(guards_for_hook.lock().expect("lock").take());
3714                assert!(
3715                    !incoming_keep_for_hook.exists(),
3716                    "receive path drops live keep markers after publication"
3717                );
3718            },
3719        );
3720
3721        repack_all(
3722            &repo,
3723            &RepackOptions {
3724                auto_skip: false,
3725                write_bitmap: false,
3726                ..RepackOptions::default()
3727            },
3728        )
3729        .expect("repack");
3730
3731        assert!(
3732            repo.odb().exists(&incoming),
3733            "inventory-time keep must protect the pack after live markers are dropped"
3734        );
3735        assert!(Command::new("git")
3736            .current_dir(&remote)
3737            .args(["fsck", "--strict"])
3738            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3739            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3740            .status()
3741            .expect("fsck")
3742            .success());
3743    }
3744
3745    #[test]
3746    fn pack_install_stage_name_matches_only_grit_staging_dirs() {
3747        assert!(is_grit_pack_install_stage_name(
3748            ".pack-deadbeef-install-1_0"
3749        ));
3750        assert!(!is_grit_pack_install_stage_name(".notes-install-backup"));
3751        assert!(!is_grit_pack_install_stage_name(".cache-install-state"));
3752        assert!(is_grit_pack_install_stage_name(
3753            ".pack-deadbeef-install-stale-file"
3754        ));
3755    }
3756
3757    #[test]
3758    fn gc_prunes_stale_tmp_pack_and_install_dirs() {
3759        let (_dir, repo) = repo_with_config("");
3760        let pack_dir = repo.git_dir().join("objects/pack");
3761        fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3762        fs::write(pack_dir.join("tmp_pack_fresh"), b"new").unwrap();
3763        fs::create_dir(pack_dir.join(".pack-deadbeef-install-9_0")).unwrap();
3764        fs::write(pack_dir.join(".notes-install-backup"), b"notes").unwrap();
3765        fs::create_dir(pack_dir.join(".cache-install-state")).unwrap();
3766        fs::write(
3767            pack_dir.join(".pack-deadbeef-install-stale-file"),
3768            b"not a stage dir",
3769        )
3770        .unwrap();
3771        let cutoff = SystemTime::now() - std::time::Duration::from_secs(21 * 86400);
3772        filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3773        filetime::set_file_mtime(
3774            pack_dir.join(".pack-deadbeef-install-9_0"),
3775            FileTime::from(cutoff),
3776        )
3777        .unwrap();
3778        for path in [
3779            pack_dir.join(".notes-install-backup"),
3780            pack_dir.join(".cache-install-state"),
3781            pack_dir.join(".pack-deadbeef-install-stale-file"),
3782        ] {
3783            filetime::set_file_mtime(&path, FileTime::from(cutoff)).unwrap();
3784        }
3785        gc(&repo, &GcOptions::default()).expect("gc");
3786        assert!(!pack_dir.join("tmp_pack_old").exists());
3787        assert!(!pack_dir.join(".pack-deadbeef-install-9_0").exists());
3788        assert!(pack_dir.join("tmp_pack_fresh").exists());
3789        assert!(pack_dir.join(".notes-install-backup").exists());
3790        assert!(pack_dir.join(".cache-install-state").is_dir());
3791        assert!(pack_dir.join(".pack-deadbeef-install-stale-file").is_file());
3792
3793        fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3794        filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3795        let stats = gc(
3796            &repo,
3797            &GcOptions {
3798                repack: RepackOptions {
3799                    dry_run: true,
3800                    ..RepackOptions::default()
3801                },
3802                ..GcOptions::default()
3803            },
3804        )
3805        .expect("dry-run gc");
3806        assert!(stats.prune.pruned >= 1);
3807        assert!(pack_dir.join("tmp_pack_old").exists());
3808    }
3809
3810    #[test]
3811    fn gc_prunes_stale_tmp_objdir_incoming_quarantine() {
3812        let (_dir, repo) = repo_with_config("");
3813        let objects = repo.git_dir().join("objects");
3814        let stale = objects.join("tmp_objdir-incoming-99999-0");
3815        fs::create_dir_all(stale.join("pack")).unwrap();
3816        fs::write(stale.join("pack/tmp_pack_stale"), b"partial pack").unwrap();
3817        let fresh = objects.join("tmp_objdir-incoming-1-0");
3818        fs::create_dir_all(fresh.join("pack")).unwrap();
3819        fs::write(fresh.join("pack/tmp_pack_fresh"), b"new").unwrap();
3820        let cutoff = SystemTime::now() - std::time::Duration::from_secs(21 * 86400);
3821        filetime::set_file_mtime(&stale, FileTime::from(cutoff)).unwrap();
3822        filetime::set_file_mtime(stale.join("pack/tmp_pack_stale"), FileTime::from(cutoff))
3823            .unwrap();
3824        gc(&repo, &GcOptions::default()).expect("gc");
3825        assert!(!stale.exists(), "stale receive quarantine should be pruned");
3826        assert!(fresh.exists(), "fresh receive quarantine should be kept");
3827    }
3828
3829    #[test]
3830    fn gc_reaps_stale_next_index_files_in_git_and_linked_worktree() {
3831        use crate::worktree::{add_linked_worktree, AddLinkedWorktreeOptions, WorktreeBranch};
3832
3833        let (dir, repo) = repo_with_config("");
3834        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3835        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3836        let ident = "Test <t@example.com> 1 +0000".to_string();
3837        create_commit(
3838            &repo,
3839            &CommitRequest {
3840                message: "init".into(),
3841                author: Some(ident.parse().expect("signature")),
3842                committer: Some(ident.parse().expect("signature")),
3843                allow_empty: false,
3844                sign_override: None,
3845                amend: false,
3846                assume_worktree_synced: false,
3847                skip_pre_commit_tree_match: false,
3848                staged_change_count: None,
3849                ..Default::default()
3850            },
3851            &mut NullProgress,
3852            &NeverCancel,
3853        )
3854        .expect("commit");
3855        let main_git = repo.git_dir().to_path_buf();
3856        let dead_pid = 99_999_997_u64;
3857        let live_pid = u64::from(std::process::id());
3858        fs::write(
3859            main_git.join(format!("next-index-{dead_pid}")),
3860            b"main orphan",
3861        )
3862        .unwrap();
3863        fs::write(
3864            main_git.join(format!("next-index-{live_pid}")),
3865            b"main live",
3866        )
3867        .unwrap();
3868
3869        let linked_path = dir.path().join("linked-wt");
3870        add_linked_worktree(
3871            &repo,
3872            &AddLinkedWorktreeOptions {
3873                path: linked_path.clone(),
3874                branch: Some(WorktreeBranch::Create("linked".into())),
3875                start_point: Some("HEAD".into()),
3876                force: false,
3877                lock: None,
3878                no_checkout: true,
3879                track: false,
3880            },
3881        )
3882        .expect("linked worktree");
3883        let linked_git = crate::worktree::list_worktrees(&repo)
3884            .unwrap()
3885            .into_iter()
3886            .find(|e| e.path == linked_path)
3887            .expect("linked entry")
3888            .admin_dir;
3889        fs::write(
3890            linked_git.join(format!("next-index-{dead_pid}")),
3891            b"linked orphan",
3892        )
3893        .unwrap();
3894
3895        gc(&repo, &GcOptions::default()).expect("gc");
3896        assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3897        assert!(main_git.join(format!("next-index-{live_pid}")).exists());
3898        assert!(!linked_git.join(format!("next-index-{dead_pid}")).exists());
3899
3900        fs::write(main_git.join(format!("next-index-{dead_pid}")), b"again").unwrap();
3901        gc(
3902            &repo,
3903            &GcOptions {
3904                repack: RepackOptions {
3905                    dry_run: true,
3906                    ..RepackOptions::default()
3907                },
3908                ..GcOptions::default()
3909            },
3910        )
3911        .expect("dry-run gc");
3912        assert!(main_git.join(format!("next-index-{dead_pid}")).exists());
3913        gc(&repo, &GcOptions::default()).expect("gc again");
3914        assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3915    }
3916}