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