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
1234/// Last `repack.writeBitmaps` or `pack.writeBitmaps` entry in load order (Git synonym).
1235fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1236    let repack_key = canonical_key("repack.writeBitmaps").ok();
1237    let pack_key = canonical_key("pack.writeBitmaps").ok();
1238    let mut last: Option<bool> = None;
1239    for entry in config.entries() {
1240        let key = entry.key.as_str();
1241        if repack_key.as_deref() == Some(key) || pack_key.as_deref() == Some(key) {
1242            let raw = entry.value.as_deref().unwrap_or("true").trim().to_owned();
1243            last = Some(parse_bool(&raw).map_err(|e| e.for_key(key))?);
1244        }
1245    }
1246    Ok(last.unwrap_or_else(|| repo.work_tree().is_none()))
1247}
1248
1249fn gc_should_pack_refs(repo: &Repository, config: &ConfigSet) -> Result<bool> {
1250    let Some(spec) = config.get("gc.packRefs") else {
1251        return Ok(true);
1252    };
1253    if spec.eq_ignore_ascii_case("notbare") {
1254        return Ok(repo.work_tree().is_some());
1255    }
1256    Ok(parse_bool(&spec).map_err(|e| e.for_key("gc.packRefs"))?)
1257}
1258
1259fn gc_should_write_commit_graph(config: &ConfigSet) -> Result<bool> {
1260    if config.get_bool("gc.writeCommitGraph").transpose()? == Some(false) {
1261        return Ok(false);
1262    }
1263    Ok(config.get_bool("core.commitGraph").transpose()? != Some(false))
1264}
1265
1266/// Loose objects reachable from the maintenance roots: what Git's incremental `repack -d`
1267/// packs. Unreachable loose objects stay loose so `gc.pruneExpire` can expire them.
1268fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
1269    let loose = odb.enumerate_local_loose_objects()?;
1270    if loose.is_empty() {
1271        return Ok(Vec::new());
1272    }
1273    let promised = reachable.promised()?;
1274    let reachable = reachable.get()?;
1275    let alternates = AlternateSources::load(odb)?;
1276    Ok(loose
1277        .into_iter()
1278        .map(|(oid, _)| oid)
1279        .filter(|oid| {
1280            reachable.contains(oid) && !promised.contains(oid) && !alternates.contains(oid)
1281        })
1282        .collect())
1283}
1284
1285/// [`reachable_loose_objects`] in pack order, with the path name hash of each tree and
1286/// blob found under a loose commit so versions of one file meet in the delta window.
1287///
1288/// Only loose objects are read: the walk follows loose commits from `roots` (newest
1289/// first) and descends into loose trees, stopping wherever history or a subtree is
1290/// already packed. Objects it does not reach keep their place after the walked ones
1291/// without a name hash.
1292fn named_reachable_loose_objects(
1293    odb: &Odb,
1294    reachable: &LazyReachable<'_>,
1295    roots: &[ObjectId],
1296) -> Result<(Vec<ObjectId>, HashMap<ObjectId, u32>)> {
1297    let loose = reachable_loose_objects(odb, reachable)?;
1298    let mut pending: OidHashSet = loose.iter().copied().collect();
1299    let mut order = Vec::with_capacity(loose.len());
1300    let mut names = HashMap::new();
1301    let mut commits = Vec::new();
1302    let mut root_trees = Vec::new();
1303    let mut stack: Vec<ObjectId> = roots
1304        .iter()
1305        .filter(|oid| pending.contains(*oid))
1306        .copied()
1307        .collect();
1308    let mut seen = OidHashSet::default();
1309    while let Some(oid) = stack.pop() {
1310        if !seen.insert(oid) {
1311            continue;
1312        }
1313        match odb.read_info(&oid)?.kind {
1314            ObjectKind::Commit => {
1315                let obj = odb.read(&oid)?;
1316                let commit = CommitRef::parse(&obj.data)?;
1317                commits.push((
1318                    commit.committer_unix_seconds_for_ordering(),
1319                    oid,
1320                    commit.tree(),
1321                ));
1322                stack.extend(commit.parents().iter().filter(|p| pending.contains(*p)));
1323            }
1324            ObjectKind::Tag => {
1325                let obj = odb.read(&oid)?;
1326                pending.remove(&oid);
1327                order.push(oid);
1328                stack.extend(tag_object_line_oid(&obj.data).filter(|t| pending.contains(t)));
1329            }
1330            ObjectKind::Tree => root_trees.push(oid),
1331            ObjectKind::Blob => {}
1332        }
1333    }
1334    commits.sort_by_key(|&(time, _, _)| std::cmp::Reverse(time));
1335    for &(_, oid, _) in &commits {
1336        pending.remove(&oid);
1337        order.push(oid);
1338    }
1339    let hash_len = odb.hash_algo().len();
1340    let mut trees: Vec<(ObjectId, Option<u32>)> = Vec::new();
1341    for tree in commits.iter().map(|&(_, _, tree)| tree).chain(root_trees) {
1342        trees.push((tree, None));
1343        while let Some((tree, name_hash)) = trees.pop() {
1344            if !pending.remove(&tree) {
1345                continue;
1346            }
1347            order.push(tree);
1348            if let Some(hash) = name_hash {
1349                names.insert(tree, hash);
1350            }
1351            let obj = odb.read(&tree)?;
1352            for entry in TreeRefIter::with_oid_len(&obj.data, hash_len) {
1353                let entry = entry?;
1354                if FileMode::new(entry.mode).is_gitlink() || !pending.contains(&entry.oid) {
1355                    continue;
1356                }
1357                let hash = pack_name_hash_child(name_hash, entry.name);
1358                if entry.mode & S_IFMT == S_IFDIR {
1359                    trees.push((entry.oid, Some(hash)));
1360                } else {
1361                    pending.remove(&entry.oid);
1362                    order.push(entry.oid);
1363                    names.insert(entry.oid, hash);
1364                }
1365            }
1366        }
1367    }
1368    order.extend(loose.into_iter().filter(|oid| pending.contains(oid)));
1369    Ok((order, names))
1370}
1371
1372/// Whether maintenance should skip rebuilding packs when [`RepackOptions::auto_skip`] is set.
1373///
1374/// Used by [`gc`]: skip when a single pack already holds all objects and nothing is loose.
1375/// Multiple packs never skip (consolidation required), even when `gc.autoPackLimit` is zero.
1376fn should_auto_skip_repack(
1377    loose_objects: usize,
1378    pack_count: usize,
1379    incremental: bool,
1380    auto_skip: bool,
1381    _config: Option<&ConfigSet>,
1382) -> bool {
1383    if !auto_skip || incremental {
1384        return false;
1385    }
1386    if pack_count > 1 {
1387        return false;
1388    }
1389    if pack_count == 1 {
1390        return loose_objects == 0;
1391    }
1392    loose_objects == 0
1393}
1394
1395/// True when skipping repack would leave unreachable objects in pack storage that repack would
1396/// remove under `opts` (for example expired unreachable commits when `gc.pruneExpire=now`).
1397///
1398/// The reachability walk only runs once some pack is old enough to have objects dropped.
1399fn skipping_repack_would_retain_prunable_packed_objects(
1400    odb: &Odb,
1401    pack_dir: &Path,
1402    reachable: &LazyReachable<'_>,
1403    opts: &RepackOptions,
1404) -> Result<bool> {
1405    if opts.keep_unreachable {
1406        return Ok(false);
1407    }
1408    let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
1409        UnreachablePackRetention::KeepGraceProtected(cutoff)
1410    } else {
1411        UnreachablePackRetention::DropAll
1412    };
1413    let indexes = odb.local_pack_indexes()?;
1414    for idx in indexes {
1415        if idx.is_promisor || pack_has_keep_marker(pack_dir, &idx.pack_path) {
1416            continue;
1417        }
1418        let pack_mtime = fs::metadata(&idx.pack_path)
1419            .and_then(|m| m.modified())
1420            .unwrap_or(SystemTime::UNIX_EPOCH);
1421        let drop_unreachable_from_pack = match retention {
1422            UnreachablePackRetention::KeepAll => false,
1423            UnreachablePackRetention::DropAll => true,
1424            UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
1425        };
1426        if !drop_unreachable_from_pack {
1427            continue;
1428        }
1429        let reachable = reachable.get()?;
1430        for entry in idx.iter() {
1431            let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
1432                continue;
1433            };
1434            if !reachable.contains(&oid) {
1435                return Ok(true);
1436            }
1437        }
1438    }
1439    Ok(false)
1440}
1441
1442/// Delete unreachable loose objects using `gc.pruneExpire` or an explicit cutoff.
1443///
1444/// Also removes stale temporary object paths (interrupted fetch/repack/index-pack leftovers)
1445/// under the same expiry rules as Git's `git gc` / `git prune` temporary-file sweep.
1446///
1447/// # Errors
1448///
1449/// Propagates config parse failures, unsupported ODB backends, and I/O errors.
1450pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
1451    let odb = repo.odb();
1452    odb.require_files_primary("prune")?;
1453    let cfg = repo.config()?;
1454    let now = gc_now_unix(0);
1455    let keep_newer_than = match opts.older_than {
1456        Some(t) => Some(t),
1457        None => match gc_loose_prune_policy(&cfg, now)? {
1458            GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
1459            GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
1460        },
1461    };
1462    let roots = collect_maintenance_object_roots(repo)?;
1463    let shallow = load_shallow_boundaries(repo.git_dir());
1464    let mut stats = prune_loose_unreachable_with(
1465        odb,
1466        &LazyReachable::new(odb, &roots, &shallow),
1467        keep_newer_than,
1468        opts.dry_run,
1469    )?;
1470    let objects_dir = odb
1471        .files_objects_dir()
1472        .ok_or(Error::UnsupportedObjectStore {
1473            operation: "prune temporary objects",
1474        })?;
1475    let temp =
1476        prune_stale_temporary_objects_with_cutoff(objects_dir, keep_newer_than, opts.dry_run)?;
1477    stats.pruned += temp.pruned;
1478    stats.kept += temp.kept;
1479    Ok(stats)
1480}
1481
1482/// Remove stale temporary object files and directories left by interrupted maintenance or
1483/// fetch operations.
1484///
1485/// Uses the same cutoff semantics as unreachable loose-object pruning: when
1486/// `keep_newer_than` is `None` (`gc.pruneExpire=now|all`), every temporary entry is
1487/// eligible; otherwise only entries whose modification time is not newer than the cutoff
1488/// are removed. When `keep_newer_than` is `None` and the caller wants to skip entirely,
1489/// pass `GcLoosePrunePolicy::Disabled` at the call site instead of calling this function.
1490///
1491/// # Errors
1492///
1493/// Returns I/O errors from directory traversal or removal.
1494pub fn prune_stale_temporary_objects(
1495    repo: &Repository,
1496    keep_newer_than: Option<SystemTime>,
1497    dry_run: bool,
1498) -> Result<PruneStats> {
1499    repo.odb()
1500        .require_files_primary("prune temporary objects")?;
1501    let objects_dir = repo
1502        .odb()
1503        .files_objects_dir()
1504        .ok_or(Error::UnsupportedObjectStore {
1505            operation: "prune temporary objects",
1506        })?;
1507    prune_stale_temporary_objects_with_cutoff(objects_dir, keep_newer_than, dry_run)
1508}
1509
1510fn prune_stale_temporary_objects_with_cutoff(
1511    objects_dir: &Path,
1512    keep_newer_than: Option<SystemTime>,
1513    dry_run: bool,
1514) -> Result<PruneStats> {
1515    let mut stats = PruneStats::default();
1516    let mut consider = |path: &Path| -> Result<()> {
1517        let meta = match fs::symlink_metadata(path) {
1518            Ok(m) => m,
1519            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1520            Err(e) => return Err(Error::Io(e)),
1521        };
1522        if temporary_object_expired(&meta, keep_newer_than) {
1523            if dry_run {
1524                stats.pruned += 1;
1525            } else if meta.is_dir() {
1526                fs::remove_dir_all(path).map_err(Error::Io)?;
1527                stats.pruned += 1;
1528            } else {
1529                fs::remove_file(path).map_err(Error::Io)?;
1530                stats.pruned += 1;
1531            }
1532        } else {
1533            stats.kept += 1;
1534        }
1535        Ok(())
1536    };
1537
1538    if let Ok(entries) = fs::read_dir(objects_dir) {
1539        for entry in entries.flatten() {
1540            let name = entry.file_name();
1541            let name = name.to_string_lossy();
1542            if name.starts_with("tmp_") {
1543                consider(&entry.path())?;
1544            } else if name.len() == 2 && name.as_bytes().iter().all(|b| b.is_ascii_hexdigit()) {
1545                if let Ok(loose) = fs::read_dir(entry.path()) {
1546                    for loose_entry in loose.flatten() {
1547                        let loose_name = loose_entry.file_name();
1548                        if loose_name.to_string_lossy().starts_with("tmp_obj_") {
1549                            consider(&loose_entry.path())?;
1550                        }
1551                    }
1552                }
1553            }
1554        }
1555    }
1556
1557    let pack_dir = objects_dir.join("pack");
1558    if let Ok(entries) = fs::read_dir(&pack_dir) {
1559        for entry in entries.flatten() {
1560            let name = entry.file_name();
1561            let name = name.to_string_lossy();
1562            if name.starts_with("tmp_") {
1563                consider(&entry.path())?;
1564                continue;
1565            }
1566            if !is_grit_pack_install_stage_name(&name) {
1567                continue;
1568            }
1569            let meta = match fs::symlink_metadata(entry.path()) {
1570                Ok(m) => m,
1571                Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
1572                Err(e) => return Err(Error::Io(e)),
1573            };
1574            if meta.is_dir() {
1575                consider(&entry.path())?;
1576            }
1577        }
1578    }
1579
1580    let incoming = objects_dir.join("incoming");
1581    if let Ok(entries) = fs::read_dir(&incoming) {
1582        for entry in entries.flatten() {
1583            let name = entry.file_name();
1584            let name = name.to_string_lossy();
1585            if name.starts_with("pack-receive-") && name.ends_with(".tmp") {
1586                consider(&entry.path())?;
1587            }
1588        }
1589    }
1590    Ok(stats)
1591}
1592
1593/// Grit index-pack staging directory: `.pack-<oid>-install-<pid>_<seq>`.
1594fn is_grit_pack_install_stage_name(name: &str) -> bool {
1595    name.starts_with(".pack-") && name.contains("-install-")
1596}
1597
1598fn temporary_object_expired(meta: &fs::Metadata, keep_newer_than: Option<SystemTime>) -> bool {
1599    match keep_newer_than {
1600        None => true,
1601        Some(cutoff) => meta
1602            .modified()
1603            .ok()
1604            .is_none_or(|modified| modified <= cutoff),
1605    }
1606}
1607
1608fn duration_ms(d: std::time::Duration) -> u64 {
1609    d.as_millis() as u64
1610}
1611
1612/// Run pack-refs, reflog expiry, repack, prune, and commit-graph maintenance.
1613///
1614/// Reclaims stale `next-index-<pid>` hook index copies (from interrupted commits) before any
1615/// phase, including when [`RepackOptions::dry_run`] is set (dry-run counts but does not delete).
1616///
1617/// Unless [`RepackOptions::dry_run`] is set, holds the repository's `gc.pid` lock for the
1618/// whole run, so it never overlaps another grit or Git gc (see [`GcLock`]).
1619///
1620/// # Errors
1621///
1622/// [`Error::MaintenanceRunning`] when another process holds the `gc.pid` lock, plus failures
1623/// from each maintenance step.
1624pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
1625    repo.odb().require_files_primary("gc")?;
1626    let now = gc_now_unix(opts.now_unix);
1627    let _lock = if opts.repack.dry_run {
1628        None
1629    } else {
1630        match GcLock::try_acquire(repo, now)? {
1631            GcLockAttempt::Acquired(lock) => Some(lock),
1632            GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
1633        }
1634    };
1635    gc_locked(repo, opts, now)
1636}
1637
1638/// Why automatic maintenance is due, as decided by [`auto_gc_needed`].
1639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1640pub enum AutoGcReason {
1641    /// The loose-object estimate is above `gc.auto`. [`gc_auto`] packs the reachable loose
1642    /// objects into one new pack and leaves existing packs alone.
1643    TooManyLoose {
1644        /// Loose objects in the sampled fan-out directory (`objects/17`) times 256.
1645        estimate: usize,
1646        /// Effective `gc.auto` threshold.
1647        limit: usize,
1648    },
1649    /// There are more non-`.keep` packs than `gc.autoPackLimit`. [`gc_auto`] consolidates
1650    /// every pack into one.
1651    TooManyPacks {
1652        /// Local packs without a `.keep` marker.
1653        count: usize,
1654        /// Effective `gc.autoPackLimit`.
1655        limit: usize,
1656    },
1657}
1658
1659/// Fan-out directory sampled to estimate the loose-object count, as Git documents for `gc.auto`.
1660const AUTO_GC_SAMPLE_FANOUT: &str = "17";
1661
1662/// Decide whether automatic maintenance is due, using Git's `gc --auto` heuristics.
1663///
1664/// Pack consolidation wins when the number of local packs without a `.keep` marker is above
1665/// [`ConfigSet::gc_auto_pack_limit`]. Otherwise the loose-object count is estimated from a
1666/// single fan-out directory (`objects/17`) and compared with [`ConfigSet::gc_auto_threshold`],
1667/// so the check costs two directory reads however large the repository is. `gc.auto <= 0`
1668/// turns both checks off. Repositories without a files object store never need it.
1669///
1670/// This does not look at `maintenance.auto`; callers that start maintenance after a command
1671/// check [`ConfigSet::maintenance_auto`] themselves.
1672///
1673/// # Errors
1674///
1675/// Config load failures and I/O errors other than a missing directory.
1676pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
1677    let cfg = repo.config()?;
1678    let Some(threshold) = cfg.gc_auto_threshold() else {
1679        return Ok(None);
1680    };
1681    let Some(objects_dir) = repo.odb().files_objects_dir() else {
1682        return Ok(None);
1683    };
1684    if let Some(limit) = cfg.gc_auto_pack_limit() {
1685        let count = count_pack_files(&objects_dir.join("pack"));
1686        if count > limit {
1687            return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
1688        }
1689    }
1690    let name_len = repo.odb().hash_algo().hex_len() - 2;
1691    let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
1692    if sampled > threshold.div_ceil(256) {
1693        return Ok(Some(AutoGcReason::TooManyLoose {
1694            estimate: sampled.saturating_mul(256),
1695            limit: threshold,
1696        }));
1697    }
1698    Ok(None)
1699}
1700
1701/// Count entries of one loose-object fan-out directory that look like object files.
1702fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
1703    let entries = match fs::read_dir(dir) {
1704        Ok(entries) => entries,
1705        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
1706        Err(e) => return Err(Error::Io(e)),
1707    };
1708    Ok(entries
1709        .filter_map(|e| e.ok())
1710        .filter(|e| {
1711            let name = e.file_name();
1712            let name = name.as_encoded_bytes();
1713            name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
1714        })
1715        .count())
1716}
1717
1718/// Result of [`gc_auto`].
1719#[derive(Debug, Clone, PartialEq, Eq)]
1720#[non_exhaustive]
1721pub enum AutoGcOutcome {
1722    /// Neither threshold is exceeded (or `gc.auto` is off); nothing ran.
1723    NotNeeded,
1724    /// Another process holds the `gc.pid` lock; nothing ran.
1725    AlreadyRunning(GcLockHolder),
1726    /// The `pre-auto-gc` hook exited non-zero; nothing ran.
1727    DeclinedByHook {
1728        /// Exit status of the hook.
1729        status: i32,
1730        /// What the hook wrote, when hook output is captured ([`Repository::hook_stdio`]).
1731        output: crate::hooks::HookOutput,
1732    },
1733    /// Maintenance ran.
1734    Ran {
1735        /// Which threshold triggered the run.
1736        reason: AutoGcReason,
1737        /// What each step did.
1738        stats: Box<GcStats>,
1739    },
1740}
1741
1742/// Run maintenance only when Git's `gc --auto` heuristics say it is due ([`auto_gc_needed`]).
1743///
1744/// When too many loose objects triggered the run, the reachable ones are packed into one new
1745/// pack without rewriting existing packs and without a bitmap, so the cost scales with the
1746/// loose objects rather than the repository. Unreachable loose objects stay loose until
1747/// `gc.pruneExpire` lets the prune step delete them, as in Git. Too many packs runs a full consolidating [`gc`]. Either way the
1748/// other `opts` steps (pack-refs, reflog expiry, unreachable prune under `gc.pruneExpire`,
1749/// commit-graph) run as in [`gc`].
1750///
1751/// Before doing any work this takes the `gc.pid` lock ([`GcLock`]) and runs the `pre-auto-gc`
1752/// hook; a held lock or a failing hook ends the call without changes. With
1753/// [`RepackOptions::dry_run`] neither happens and the returned stats describe the planned work.
1754///
1755/// The library never forks: callers that honor [`ConfigSet::gc_auto_detach`] start a separate
1756/// process that calls this function.
1757///
1758/// # Errors
1759///
1760/// Config, hook spawn, lock and I/O failures, and any error from the maintenance steps.
1761pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
1762    let Some(reason) = auto_gc_needed(repo)? else {
1763        return Ok(AutoGcOutcome::NotNeeded);
1764    };
1765    let now = gc_now_unix(opts.now_unix);
1766    let dry_run = opts.repack.dry_run;
1767    let _lock = if dry_run {
1768        None
1769    } else {
1770        match GcLock::try_acquire(repo, now)? {
1771            GcLockAttempt::Acquired(lock) => Some(lock),
1772            GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
1773        }
1774    };
1775    if !dry_run {
1776        if let crate::hooks::HookResult::Failed { status, output } =
1777            crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
1778        {
1779            return Ok(AutoGcOutcome::DeclinedByHook { status, output });
1780        }
1781    }
1782    let mut run_opts = opts.clone();
1783    if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
1784        run_opts.repack.incremental = true;
1785        run_opts.repack.write_bitmap = false;
1786    }
1787    let stats = gc_locked(repo, &run_opts, now)?;
1788    Ok(AutoGcOutcome::Ran {
1789        reason,
1790        stats: Box::new(stats),
1791    })
1792}
1793
1794/// Process recorded in `gc.pid` by a maintenance run that is still in progress.
1795#[derive(Debug, Clone, PartialEq, Eq)]
1796pub struct GcLockHolder {
1797    /// Process id from `gc.pid`, when it could be read.
1798    pub pid: Option<u64>,
1799    /// Host name from `gc.pid`, when it could be read.
1800    pub host: Option<String>,
1801}
1802
1803/// Outcome of [`GcLock::try_acquire`].
1804#[derive(Debug)]
1805#[must_use]
1806pub enum GcLockAttempt {
1807    /// This process now owns `gc.pid`.
1808    Acquired(GcLock),
1809    /// Another live maintenance run owns it.
1810    Held(GcLockHolder),
1811}
1812
1813/// How long a `gc.pid` written by another run is honored before it is treated as stale.
1814const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
1815
1816/// Repository-wide maintenance lock, compatible with Git's `gc.pid`.
1817///
1818/// The lock file holds `"<pid> <hostname>"` and is removed when this value is dropped. An
1819/// existing `gc.pid` counts as held while it is younger than 12 hours and either names another
1820/// host or a process on this host that is still running; older or dead entries are taken over.
1821#[derive(Debug)]
1822pub struct GcLock {
1823    path: PathBuf,
1824}
1825
1826impl GcLock {
1827    /// Try to take the `gc.pid` lock in the repository's common directory without waiting.
1828    ///
1829    /// `now_unix` is the wall clock used to decide whether an existing `gc.pid` is stale.
1830    ///
1831    /// # Errors
1832    ///
1833    /// I/O failures while creating, writing or renaming the lock file.
1834    pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
1835        let path = repo.common_git_dir().join("gc.pid");
1836        let mut lock = match crate::lockfile::LockFile::acquire(
1837            &path.with_extension("pid.lock"),
1838            crate::lockfile::LockWait::Immediately,
1839        ) {
1840            Ok(lock) => lock,
1841            Err(crate::lockfile::LockError::Held { .. }) => {
1842                return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
1843                    GcLockHolder {
1844                        pid: None,
1845                        host: None,
1846                    },
1847                )));
1848            }
1849            Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
1850        };
1851        let host = local_host_name();
1852        if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
1853            lock.rollback();
1854            return Ok(GcLockAttempt::Held(holder));
1855        }
1856        use std::io::Write as _;
1857        write!(lock, "{} {host}", std::process::id())?;
1858        lock.commit_to(&path).map_err(|e| match e {
1859            crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
1860            held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
1861        })?;
1862        Ok(GcLockAttempt::Acquired(GcLock { path }))
1863    }
1864
1865    /// The maintenance run currently holding `gc.pid`, without taking the lock.
1866    ///
1867    /// Uses the same staleness rules as [`Self::try_acquire`], so `None` means an attempt would
1868    /// most likely succeed. Lets a caller avoid starting a process that would only find the
1869    /// lock held.
1870    #[must_use]
1871    pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
1872        let path = repo.common_git_dir().join("gc.pid");
1873        live_gc_pid_holder(&path, &local_host_name(), now_unix)
1874    }
1875}
1876
1877impl Drop for GcLock {
1878    fn drop(&mut self) {
1879        let _ = fs::remove_file(&self.path);
1880    }
1881}
1882
1883fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
1884    let raw = fs::read_to_string(path).ok()?;
1885    let mut fields = raw.split_whitespace();
1886    let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
1887    let host = fields.next().map(str::to_owned);
1888    Some(GcLockHolder { pid, host })
1889}
1890
1891/// The holder recorded in `gc.pid` when it still blocks a new run on `host`.
1892fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
1893    let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
1894    let mtime = OffsetDateTime::from(mtime);
1895    let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
1896    if now - mtime > GC_PID_STALE_AFTER {
1897        return None;
1898    }
1899    let holder = read_gc_pid(path)?;
1900    let pid = holder.pid?;
1901    let same_host = holder.host.as_deref() == Some(host);
1902    if same_host && !crate::lockfile::is_process_running(pid) {
1903        return None;
1904    }
1905    Some(holder)
1906}
1907
1908/// Host name as Git records it in `gc.pid` (`gethostname`), so either tool can tell whether
1909/// the other's lock belongs to this machine.
1910fn local_host_name() -> String {
1911    #[cfg(unix)]
1912    {
1913        if let Ok(uts) = nix::sys::utsname::uname() {
1914            if let Some(name) = uts.nodename().to_str() {
1915                return name.to_owned();
1916            }
1917        }
1918    }
1919    #[cfg(windows)]
1920    {
1921        if let Some(name) = windows_dns_host_name() {
1922            return name;
1923        }
1924    }
1925    "localhost".to_owned()
1926}
1927
1928/// The DNS host name, which is what Winsock `gethostname` (used by Git for Windows) returns.
1929#[cfg(windows)]
1930fn windows_dns_host_name() -> Option<String> {
1931    use windows_sys::Win32::System::SystemInformation::{
1932        ComputerNameDnsHostname, GetComputerNameExW,
1933    };
1934    let mut len: u32 = 0;
1935    // SAFETY: a null buffer with zero length only asks for the required size in `len`.
1936    unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
1937    if len == 0 {
1938        return None;
1939    }
1940    let mut buf = vec![0u16; len as usize];
1941    // SAFETY: `buf` has room for the `len` UTF-16 units the previous call asked for.
1942    let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
1943    (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
1944}
1945
1946fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
1947    let mut stats = GcStats::default();
1948    let dry_run = opts.repack.dry_run;
1949    let _ = crate::porcelain::commit::reap_stale_next_index_files(repo, dry_run)?;
1950    let cfg = repo.config()?;
1951    let prune_policy = gc_loose_prune_policy(&cfg, now)?;
1952    // Settled before any phase, so a bad config value changes nothing.
1953    let drop_plan = if opts.drop_large_blobs {
1954        Some(drop_blobs::plan_drop_for_gc(&cfg, opts.now_unix)?)
1955    } else {
1956        None
1957    };
1958    if opts.pack_refs && gc_should_pack_refs(repo, &cfg)? && !dry_run {
1959        let start = Instant::now();
1960        stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
1961        stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
1962    }
1963
1964    if opts.expire_reflog {
1965        let start = Instant::now();
1966        stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
1967        stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
1968    }
1969
1970    let mut repack_opts = opts.repack.clone();
1971    apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
1972    if repack_opts.now.is_none() {
1973        repack_opts.now =
1974            Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
1975    }
1976    // Incremental runs skip bitmaps, as Git's `gc --auto` does: a bitmap covers the whole
1977    // object graph and costs far more than packing the loose objects.
1978    if !repack_opts.write_bitmap && !repack_opts.incremental {
1979        repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg)?;
1980    }
1981    let repack_start = Instant::now();
1982    stats.repack = repack_all(repo, &repack_opts)?;
1983    let repack_total_ms = duration_ms(repack_start.elapsed());
1984    stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
1985    stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
1986
1987    // Repack leaves promisor packs as they are, so the drop phase sees them whole.
1988    if let Some(plan) = drop_plan {
1989        let start = Instant::now();
1990        stats.drop_large_blobs = Some(drop_blobs::run_drop_for_gc(
1991            repo,
1992            &cfg,
1993            plan,
1994            dry_run,
1995            opts.repack.threads,
1996        )?);
1997        stats.phase_timings.drop_large_blobs_ms = duration_ms(start.elapsed());
1998    }
1999
2000    // Repack prunes unreachable loose objects itself (sharing its reachability walk), so gc
2001    // only reports the outcome.
2002    stats.prune_ran = stats.repack.unreachable_prune_ran;
2003    stats.prune = PruneStats {
2004        pruned: stats.repack.unreachable_loose_pruned,
2005        kept: stats.repack.unreachable_loose_kept,
2006    };
2007
2008    if let GcLoosePrunePolicy::Enabled { keep_newer_than } = &prune_policy {
2009        let temp = prune_stale_temporary_objects_with_cutoff(
2010            repo.odb()
2011                .files_objects_dir()
2012                .ok_or(Error::UnsupportedObjectStore { operation: "gc" })?,
2013            *keep_newer_than,
2014            dry_run,
2015        )?;
2016        stats.prune.pruned += temp.pruned;
2017        stats.prune.kept += temp.kept;
2018    }
2019
2020    if dry_run {
2021        return Ok(stats);
2022    }
2023
2024    if opts.write_commit_graph && gc_should_write_commit_graph(&cfg)? {
2025        let start = Instant::now();
2026        stats.commit_graph = Some(write_commit_graph(
2027            repo,
2028            &CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
2029        )?);
2030        stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
2031    }
2032    Ok(stats)
2033}
2034
2035/// How [`gc`] applies loose-object pruning for `gc.pruneExpire`.
2036enum GcLoosePrunePolicy {
2037    /// `gc.pruneExpire=never` — do not delete unreachable loose objects.
2038    Disabled,
2039    /// Prune unreachable loose objects not protected by the grace cutoff.
2040    Enabled {
2041        /// When `Some(t)`, only delete loose objects strictly older than `t` (Git grace window).
2042        keep_newer_than: Option<SystemTime>,
2043    },
2044}
2045
2046fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
2047    let spec = config
2048        .get("gc.pruneExpire")
2049        .map(|s| s.trim().to_owned())
2050        .filter(|s| !s.is_empty())
2051        .unwrap_or_else(|| "2.weeks.ago".to_owned());
2052    if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
2053        return Ok(GcLoosePrunePolicy::Disabled);
2054    }
2055    // Git expires everything for `now` and `all`, including objects written this second.
2056    if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
2057        return Ok(GcLoosePrunePolicy::Enabled {
2058            keep_newer_than: None,
2059        });
2060    }
2061    let mut err = 0;
2062    let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
2063    if err != 0 {
2064        return Err(Error::Config(ConfigError::Other(format!(
2065            "bad config value '{spec}' for 'gc.pruneExpire'"
2066        ))));
2067    }
2068    let keep_newer_than = SystemTime::UNIX_EPOCH
2069        .checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
2070    Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
2071}
2072
2073fn gc_now_unix(now_unix: i64) -> i64 {
2074    if now_unix != 0 {
2075        return now_unix;
2076    }
2077    time::OffsetDateTime::now_utc().unix_timestamp()
2078}
2079
2080fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
2081    let cfg = repo.config()?;
2082    let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
2083    let params = ReflogExpireParams {
2084        stale_fix: false,
2085        dry_run,
2086        verbose: false,
2087    };
2088    let mut total = 0usize;
2089    let cache = ReflogExpiryCache::default();
2090    for refname in reflog_refs_on_store(repo.refs())? {
2091        let result = expire_reflog_git_with_cache(
2092            repo,
2093            &refname,
2094            &params,
2095            None,
2096            None,
2097            &gc_cfg.patterns,
2098            gc_cfg.global_total,
2099            gc_cfg.global_unreachable,
2100            now,
2101            &cache,
2102        )?;
2103        total += result.pruned;
2104    }
2105    Ok(total)
2106}
2107
2108fn count_loose_objects(odb: &Odb) -> Result<usize> {
2109    Ok(odb.enumerate_local_loose_objects()?.len())
2110}
2111
2112/// Build the all-into-one repack pack for `roots`, stopping history at the `shallow`
2113/// boundary commits.
2114#[allow(clippy::too_many_arguments)]
2115fn build_repack_pack(
2116    odb: &Odb,
2117    pack_dir: &Path,
2118    roots: &[ObjectId],
2119    shallow: &HashSet<ObjectId>,
2120    keep_unreachable: bool,
2121    opts: &PackBuildOptions,
2122    threads: Option<usize>,
2123    cfg: Option<&ConfigSet>,
2124    promised: &HashSet<ObjectId>,
2125) -> Result<SerializedDeltaPack> {
2126    let selection = repack_object_list(
2127        odb,
2128        roots,
2129        shallow,
2130        keep_unreachable,
2131        opts.use_bitmaps,
2132        promised,
2133    )?;
2134    write_staged_repack_pack(
2135        odb,
2136        pack_dir,
2137        &selection.send,
2138        &selection.name_hashes,
2139        opts,
2140        threads,
2141        cfg,
2142    )
2143}
2144
2145/// Stream a local pack holding exactly `objects` to a `tmp_repack_*` file in `pack_dir`,
2146/// with the delta search tuned by `opts` (`pack.window`, `pack.depth`) and
2147/// `pack.windowMemory` from `cfg`.
2148///
2149/// Every delta base is one of `objects`, so the pack is self-contained. `name_hashes`
2150/// pairs same-path blobs and trees in the search window. An empty `objects` writes
2151/// nothing and returns an empty pack.
2152///
2153/// # Errors
2154///
2155/// Propagates thread-option, object read, and staging-file I/O failures; the staging
2156/// file is removed when writing fails.
2157pub(crate) fn write_staged_repack_pack(
2158    odb: &Odb,
2159    pack_dir: &Path,
2160    objects: &[ObjectId],
2161    name_hashes: &HashMap<ObjectId, u32>,
2162    opts: &PackBuildOptions,
2163    threads: Option<usize>,
2164    cfg: Option<&ConfigSet>,
2165) -> Result<SerializedDeltaPack> {
2166    if objects.is_empty() {
2167        return Ok(SerializedDeltaPack {
2168            bytes: Vec::new(),
2169            index_rows: Vec::new(),
2170            staging_path: None,
2171        });
2172    }
2173    odb.clear_delta_base_cache();
2174    let max_depth = if opts.delta { opts.max_depth } else { 0 };
2175    let settings = RepackPackSettings {
2176        window: opts.window,
2177        max_depth,
2178        reuse_deltas: opts.reuse_deltas,
2179        reuse_objects: opts.reuse_objects,
2180        use_ofs_delta: opts.use_ofs_delta,
2181        window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
2182        search_threads: pack_delta_search_parallel_options(odb, threads)?
2183            .threads
2184            .get(),
2185        write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
2186    };
2187    let git_dir = odb
2188        .config_git_dir()
2189        .map(Path::to_path_buf)
2190        .unwrap_or_else(|| {
2191            pack_dir
2192                .parent()
2193                .and_then(|objects| objects.parent())
2194                .map(Path::to_path_buf)
2195                .unwrap_or_else(|| pack_dir.to_path_buf())
2196        });
2197    crate::git_dir::io_error::io_with_git_path(
2198        &git_dir,
2199        pack_dir,
2200        crate::git_dir::GitDirPathOperation::GcPack,
2201        fs::create_dir_all(pack_dir),
2202    )?;
2203    let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
2204    let written = write_repack_pack(odb, objects, name_hashes, &settings, &staging);
2205    match written {
2206        Ok(index_rows) => Ok(SerializedDeltaPack {
2207            bytes: Vec::new(),
2208            index_rows,
2209            staging_path: Some(staging),
2210        }),
2211        Err(err) => {
2212            let _ = fs::remove_file(&staging);
2213            Err(err)
2214        }
2215    }
2216}
2217
2218/// Objects a full repack writes: everything reachable from `roots` down to the
2219/// `shallow` boundary commits, plus every other packed and loose object when
2220/// `keep_unreachable` is set.
2221fn repack_object_list(
2222    odb: &Odb,
2223    roots: &[ObjectId],
2224    shallow: &HashSet<ObjectId>,
2225    keep_unreachable: bool,
2226    use_bitmaps: bool,
2227    promised: &HashSet<ObjectId>,
2228) -> Result<PackObjectSelection> {
2229    // Roots arrive in hash-set order; sorting them makes the pack (and its name)
2230    // the same on every run.
2231    let mut roots: Vec<_> = roots
2232        .iter()
2233        .copied()
2234        .filter(|oid| !promised.contains(oid))
2235        .collect();
2236    roots.sort_unstable();
2237    let alternates = AlternateSources::load(odb)?;
2238    let use_bitmaps = use_bitmaps && promised.is_empty() && !alternates.in_use();
2239    let mut selection = enumerate_pack_objects_naming(
2240        odb,
2241        &roots,
2242        &[],
2243        &PackEnumerateOptions {
2244            filter: None,
2245            shallow_grafts: shallow,
2246            have_shallow_grafts: Some(shallow),
2247            use_bitmaps,
2248            exclude_objects: Some(promised),
2249            peer_odb: None,
2250            have_boundary: crate::pack_object_select::HaveBoundary::CommitClosure,
2251        },
2252        NameTrees::Yes,
2253        true,
2254        &crate::progress::CancelSignal::never(),
2255    )?;
2256    if keep_unreachable {
2257        append_repack_unreachable_retention(odb, &mut selection.send, promised)?;
2258    }
2259    Ok(selection)
2260}
2261
2262/// Append packed and loose objects not already selected for the repack send list.
2263fn append_repack_unreachable_retention(
2264    odb: &Odb,
2265    send: &mut Vec<ObjectId>,
2266    promised: &HashSet<ObjectId>,
2267) -> Result<()> {
2268    let alternates = AlternateSources::load(odb)?;
2269    let mut in_send: OidHashSet = send.iter().copied().collect();
2270    odb.primary()?
2271        .packed_objects()
2272        .for_each_removable_pack_object(&mut |oid| {
2273            if !promised.contains(oid) && !alternates.contains(oid) && in_send.insert(*oid) {
2274                send.push(*oid);
2275            }
2276            std::ops::ControlFlow::Continue(())
2277        })?;
2278    for (oid, _) in odb.enumerate_local_loose_objects()? {
2279        if !promised.contains(&oid) && !alternates.contains(&oid) && in_send.insert(oid) {
2280            send.push(oid);
2281        }
2282    }
2283    Ok(())
2284}
2285
2286fn pack_repack_inventory(pack_dir: &Path) -> crate::odb::RepackPackInventorySnapshot {
2287    let pack_paths = local_pack_paths(pack_dir);
2288    let kept_at_inventory = pack_paths
2289        .iter()
2290        .filter(|pack_path| pack_has_keep_marker(pack_dir, pack_path))
2291        .cloned()
2292        .collect();
2293    crate::odb::RepackPackInventorySnapshot {
2294        pack_paths,
2295        kept_at_inventory,
2296    }
2297}
2298
2299/// Every local `.pack` path under `pack_dir` at inventory time.
2300fn local_pack_paths(pack_dir: &Path) -> HashSet<PathBuf> {
2301    let Ok(entries) = fs::read_dir(pack_dir) else {
2302        return HashSet::new();
2303    };
2304    entries
2305        .filter_map(|e| e.ok())
2306        .map(|e| pack_dir.join(e.file_name()))
2307        .filter(|p| p.extension().is_some_and(|e| e == "pack"))
2308        .collect()
2309}
2310
2311/// Packs in `pack_dir` without a `.keep` marker, from a single directory read.
2312fn count_pack_files(pack_dir: &Path) -> usize {
2313    let Ok(entries) = fs::read_dir(pack_dir) else {
2314        return 0;
2315    };
2316    let mut packs = HashSet::new();
2317    let mut kept = HashSet::new();
2318    for entry in entries.filter_map(|e| e.ok()) {
2319        let path = PathBuf::from(entry.file_name());
2320        let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
2321            continue;
2322        };
2323        if ext == "pack" {
2324            packs.insert(stem.to_owned());
2325        } else if ext == "keep" {
2326            kept.insert(stem.to_owned());
2327        }
2328    }
2329    packs.difference(&kept).count()
2330}
2331
2332/// Promises may be stored outside marked packs. Keep their source ordinary packs
2333/// when no retained promisor pack supplies a copy, so repacking cannot turn an
2334/// available promise into a missing one. Only indexes are read; no object fetches.
2335fn packs_holding_available_promises(
2336    odb: &Odb,
2337    promised: &HashSet<ObjectId>,
2338) -> Result<HashSet<PathBuf>> {
2339    if promised.is_empty() {
2340        return Ok(HashSet::new());
2341    }
2342    let indexes = odb.local_pack_indexes()?;
2343    let promisor_indexes: Vec<_> = indexes.iter().filter(|idx| idx.is_promisor).collect();
2344    let mut retained = HashSet::new();
2345    for idx in indexes.iter().filter(|idx| !idx.is_promisor) {
2346        for entry in idx.iter() {
2347            let oid = ObjectId::from_bytes(entry.oid())?;
2348            if promised.contains(&oid)
2349                && !promisor_indexes
2350                    .iter()
2351                    .any(|idx| idx.find_position(&oid).is_some())
2352            {
2353                retained.insert(idx.pack_path.clone());
2354                break;
2355            }
2356        }
2357    }
2358    Ok(retained)
2359}
2360
2361/// Remove pack artifacts in `pack_dir` except `keep_pack`, `.keep` packs, promisor packs, and
2362/// `retained_packs` (including every sidecar of those retained packs).
2363fn remove_superseded_pack_files_retaining(
2364    pack_dir: &Path,
2365    keep_pack: &Path,
2366    retained_packs: &HashSet<PathBuf>,
2367    inventory: &crate::odb::RepackPackInventorySnapshot,
2368) -> Result<usize> {
2369    let git_dir = crate::git_dir::io_error::git_dir_from_pack_dir(pack_dir);
2370    let keep_stem = keep_pack
2371        .file_stem()
2372        .and_then(|s| s.to_str())
2373        .ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
2374    if !pack_dir.is_dir() {
2375        return Ok(0);
2376    }
2377    let mut removed = 0usize;
2378    for entry in crate::git_dir::io_error::io_with_git_path(
2379        &git_dir,
2380        pack_dir,
2381        crate::git_dir::GitDirPathOperation::GcPack,
2382        fs::read_dir(pack_dir),
2383    )? {
2384        let entry = entry.map_err(|source| {
2385            crate::git_dir::io_error::map_git_dir_io(
2386                &git_dir,
2387                pack_dir,
2388                crate::git_dir::GitDirPathOperation::GcPack,
2389                source,
2390            )
2391        })?;
2392        let name = entry.file_name();
2393        let name_str = name.to_string_lossy();
2394        if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
2395            continue;
2396        }
2397        if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
2398            continue;
2399        }
2400        let path = entry.path();
2401        if path.is_file() {
2402            let pack_path = path.with_extension("pack");
2403            if inventory.kept_at_inventory.contains(&pack_path)
2404                || path.with_extension("promisor").is_file()
2405                || retained_packs.contains(&pack_path)
2406                || !inventory.pack_paths.contains(&pack_path)
2407            {
2408                continue;
2409            }
2410            crate::git_dir::io_error::io_with_git_path(
2411                &git_dir,
2412                &path,
2413                crate::git_dir::GitDirPathOperation::GcPack,
2414                fs::remove_file(&path),
2415            )?;
2416            removed += 1;
2417        }
2418    }
2419    Ok(removed)
2420}
2421
2422/// What [`rewrite_local_objects_without`] did.
2423#[derive(Debug, Clone, Default, PartialEq, Eq)]
2424pub(crate) struct PromisorRewrite {
2425    /// The promisor pack that now holds the kept objects; `None` when nothing was
2426    /// rewritten.
2427    pub(crate) pack_path: Option<PathBuf>,
2428    /// Objects written into that pack.
2429    pub(crate) objects: usize,
2430    /// Objects of the drop set that were stored (packed or loose) and are now gone.
2431    pub(crate) dropped: usize,
2432    /// Pack files (with their sidecars) removed.
2433    pub(crate) removed_pack_files: usize,
2434}
2435
2436/// When [`rewrite_local_objects_without`] rewrites.
2437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2438pub(crate) enum PromisorRewriteWhen {
2439    /// Only when it drops something.
2440    Dropping,
2441    /// Also to fold every local pack and loose object into one promisor pack.
2442    Always,
2443}
2444
2445/// Rewrite the repository's own packs and loose objects into one promisor pack that
2446/// leaves out `omit`.
2447///
2448/// Every object in the local packs without a `.keep` marker, promisor or not, and
2449/// every loose object goes into the new pack, except those in `omit`; there is no
2450/// reachability walk, so unreachable objects are kept and nothing else can be lost.
2451/// The new pack is marked `.promisor`, which makes the dropped objects promised as
2452/// long as an object in it refers to them (a tree naming a dropped blob, say).
2453/// The kept blobs go into the same pack, though the promisor cannot supply them:
2454/// Git counts every object a promisor-pack tree names as promised, and its repack
2455/// and `gc` keep only those promised objects that sit in a promisor pack, so a
2456/// kept blob in an ordinary pack beside it would be deleted. Maintenance that
2457/// drops promised blobs must therefore check the promisor holds each one (see
2458/// [`drop_promised_blobs`]). `.keep` packs and alternates are left alone, so
2459/// objects stored there stay. That is not enough for an object only an ordinary
2460/// (not `.promisor`) `.keep` pack holds when the new pack names it: Git then
2461/// counts it as promised and deletes it once the marker goes, and leaves a copy
2462/// out of its own promisor repack while the marker stays. Callers must rule that
2463/// out first (offload refuses with [`crate::offload::OffloadError::KeptPackObjectPromised`]).
2464///
2465/// The new pack is installed before anything is removed, and only the packs listed
2466/// when the rewrite started are removed afterwards: a pack that arrives meanwhile
2467/// (a push) survives. Then loose copies of the dropped objects and loose objects
2468/// now packed are deleted. The multi-pack-index is removed; no bitmap is written.
2469///
2470/// The new pack, its index and markers are flushed to disk before any old pack is
2471/// removed, so a crash never leaves an object in neither.
2472///
2473/// Callers hold the [`GcLock`]. With [`PromisorRewriteWhen::Dropping`], an `omit`
2474/// that names nothing stored in the rewritten packs or loose changes nothing. With
2475/// [`PromisorRewriteWhen::Always`] the rewrite also runs to fold other packs and
2476/// loose objects into one promisor pack, and is skipped only when the local
2477/// objects already are exactly one promisor pack (plus `.keep` packs).
2478///
2479/// # Errors
2480///
2481/// [`Error::UnsupportedObjectStore`] without a files-backed object store, and pack
2482/// read, write and file removal failures.
2483pub(crate) fn rewrite_local_objects_without(
2484    repo: &Repository,
2485    omit: &OidHashSet,
2486    opts: &RepackOptions,
2487    when: PromisorRewriteWhen,
2488) -> Result<PromisorRewrite> {
2489    let odb = repo.odb();
2490    odb.require_files_primary("promisor rewrite")?;
2491    if omit.is_empty() && when == PromisorRewriteWhen::Dropping {
2492        return Ok(PromisorRewrite::default());
2493    }
2494    let objects_dir = odb.objects_dir().to_path_buf();
2495    let pack_dir = objects_dir.join("pack");
2496    odb.reprepare_local_packs();
2497    let snapshot: Vec<_> = odb
2498        .local_pack_indexes()?
2499        .into_iter()
2500        .filter(|idx| !pack_has_keep_marker(&pack_dir, &idx.pack_path))
2501        .collect();
2502
2503    let mut send = Vec::new();
2504    let mut seen: OidHashSet = OidHashSet::default();
2505    let mut dropped: OidHashSet = OidHashSet::default();
2506    let mut take = |oid: ObjectId| {
2507        if omit.contains(&oid) {
2508            dropped.insert(oid);
2509        } else if seen.insert(oid) {
2510            send.push(oid);
2511        }
2512    };
2513    for idx in &snapshot {
2514        for entry in idx.iter() {
2515            take(ObjectId::from_bytes(entry.oid())?);
2516        }
2517    }
2518    let loose = odb.enumerate_local_loose_objects()?;
2519    for (oid, _) in &loose {
2520        take(*oid);
2521    }
2522    let folded = loose.is_empty() && snapshot.len() <= 1 && snapshot.iter().all(|i| i.is_promisor);
2523    let nothing_to_do = match when {
2524        PromisorRewriteWhen::Dropping => dropped.is_empty(),
2525        PromisorRewriteWhen::Always => dropped.is_empty() && folded,
2526    };
2527    if nothing_to_do {
2528        return Ok(PromisorRewrite::default());
2529    }
2530
2531    let cfg = opts
2532        .merged_config
2533        .clone()
2534        .or_else(|| repo.config().ok().map(|c| (*c).clone()));
2535    let pack_opts = opts
2536        .pack_build
2537        .unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
2538    let built = write_staged_repack_pack(
2539        odb,
2540        &pack_dir,
2541        &send,
2542        &HashMap::new(),
2543        &pack_opts,
2544        opts.threads,
2545        cfg.as_ref(),
2546    )?;
2547    clear_pack_midx_state(&pack_dir)?;
2548    let mut rewrite = PromisorRewrite {
2549        dropped: dropped.len(),
2550        ..PromisorRewrite::default()
2551    };
2552    if let Some(staging) = built.staging_path.as_ref() {
2553        let trusted_rows = (!built.index_rows.is_empty()).then_some(built.index_rows);
2554        let ingested = install_pack_path(
2555            staging,
2556            odb,
2557            &IngestPackOptions {
2558                threads: opts.threads,
2559                merged_config: cfg.clone(),
2560                environment: opts.environment.clone(),
2561                skip_post_index_verify: trusted_rows.is_some(),
2562                trusted_index_rows: trusted_rows,
2563                mark_promisor_pack: true,
2564                ..IngestPackOptions::default()
2565            },
2566        )?;
2567        rewrite.objects = ingested.object_ids.len();
2568        if let Some(pack_path) = &ingested.pack_path {
2569            if cfg
2570                .as_ref()
2571                .is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
2572            {
2573                write_pack_rev_sidecar_for_index_path(&pack_path.with_extension("idx"))?;
2574            }
2575            crate::index_pack::sync_installed_pack(pack_path)?;
2576        }
2577        rewrite.pack_path = ingested.pack_path;
2578    }
2579
2580    // Drop cached pack mmaps before deleting pack files (Windows).
2581    odb.invalidate_packs();
2582    let new_stem = rewrite
2583        .pack_path
2584        .as_deref()
2585        .and_then(Path::file_stem)
2586        .map(|s| s.to_os_string());
2587    let stems: HashSet<std::ffi::OsString> = snapshot
2588        .iter()
2589        .filter_map(|idx| idx.pack_path.file_stem().map(|s| s.to_os_string()))
2590        .filter(|stem| Some(stem) != new_stem.as_ref())
2591        .collect();
2592    drop(snapshot);
2593    rewrite.removed_pack_files = remove_pack_files_with_stems(&pack_dir, &stems)?;
2594    for oid in &dropped {
2595        odb.remove_local_loose(oid)?;
2596    }
2597    prune_packed_objects(&objects_dir, PrunePackedOptions::default())?;
2598    odb.invalidate_packs();
2599    Ok(rewrite)
2600}
2601
2602/// Remove every file in `pack_dir` named `<stem>.<ext>` for one of `stems` (the
2603/// pack, its index and every sidecar). A pack that has gained a `.keep` marker since
2604/// it was listed is left whole. Returns how many files went.
2605fn remove_pack_files_with_stems(
2606    pack_dir: &Path,
2607    stems: &HashSet<std::ffi::OsString>,
2608) -> Result<usize> {
2609    if stems.is_empty() {
2610        return Ok(0);
2611    }
2612    let mut removed = 0usize;
2613    for entry in fs::read_dir(pack_dir)?.filter_map(|e| e.ok()) {
2614        let path = entry.path();
2615        let Some(stem) = path.file_stem().filter(|stem| stems.contains(*stem)) else {
2616            continue;
2617        };
2618        if !path.is_file() || pack_dir.join(stem).with_extension("keep").is_file() {
2619            continue;
2620        }
2621        fs::remove_file(&path)?;
2622        removed += 1;
2623    }
2624    Ok(removed)
2625}
2626
2627fn pack_has_keep_marker(pack_dir: &Path, pack_path: &Path) -> bool {
2628    let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
2629        return false;
2630    };
2631    pack_dir.join(format!("{stem}.keep")).is_file()
2632}
2633
2634/// Pack loose refs into `packed-refs` (files backend) or compact reftable stacks.
2635///
2636/// With the files backend, directories emptied by removing packed loose refs are
2637/// deleted, but `refs/` itself is kept so Git still recognizes the repository.
2638///
2639/// # Errors
2640///
2641/// Propagates ref-store, locking, and I/O failures.
2642pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
2643    let (path, refs_packed, refs_pruned) =
2644        crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
2645    Ok(PackRefsReport {
2646        path,
2647        refs_packed,
2648        refs_pruned,
2649    })
2650}
2651
2652impl Repository {
2653    /// Write a reachable commit-graph file for this repository.
2654    ///
2655    /// # Errors
2656    ///
2657    /// See [`write_commit_graph`].
2658    pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
2659        write_commit_graph(self, opts)
2660    }
2661
2662    /// Build a reachable commit-graph in memory, for any object store.
2663    ///
2664    /// # Errors
2665    ///
2666    /// See [`build_commit_graph`].
2667    pub fn build_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphBuild> {
2668        build_commit_graph(self, opts)
2669    }
2670
2671    /// Write or refresh the multi-pack index for this repository's pack directory.
2672    ///
2673    /// # Errors
2674    ///
2675    /// See [`write_midx`].
2676    pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
2677        write_midx(self, opts)
2678    }
2679}
2680
2681/// Standalone object fsck helpers.
2682pub mod fsck {
2683    pub use crate::fsck_msg_policy::{
2684        FsckMsgLevel, ObjectFsckChecker, ObjectFsckPolicy, SkipListError,
2685    };
2686    pub use crate::fsck_standalone::*;
2687}
2688
2689/// Repository-wide fsck (object store, connectivity, refs).
2690pub mod repository_fsck {
2691    pub use crate::fsck::*;
2692}
2693
2694#[cfg(test)]
2695pub(crate) mod gc_test_hooks {
2696    use std::cell::RefCell;
2697
2698    /// Where in [`super::repack_detail`] a test hook interleaves another writer.
2699    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
2700    pub enum GcPausePoint {
2701        /// After pack inventory is recorded and before maintenance roots are collected.
2702        AfterInventoryBeforeRoots,
2703        /// After maintenance roots are collected and before superseded packs are dropped.
2704        AfterRootsBeforeDropAll,
2705    }
2706
2707    type GcHook = (GcPausePoint, Box<dyn FnOnce() + Send>);
2708
2709    thread_local! {
2710        static GC_HOOK: RefCell<Option<GcHook>> = const { RefCell::new(None) };
2711    }
2712
2713    /// Run `hook` during the next repack on this thread when it reaches `point`.
2714    pub fn on_next_gc(point: GcPausePoint, hook: impl FnOnce() + Send + 'static) {
2715        GC_HOOK.with(|slot| *slot.borrow_mut() = Some((point, Box::new(hook))));
2716    }
2717
2718    pub(crate) fn run(point: GcPausePoint) {
2719        let hook = GC_HOOK.with(|slot| {
2720            let mut slot = slot.borrow_mut();
2721            match slot.as_ref() {
2722                Some((at, _)) if *at == point => slot.take().map(|(_, hook)| hook),
2723                _ => None,
2724            }
2725        });
2726        if let Some(hook) = hook {
2727            hook();
2728        }
2729    }
2730}
2731
2732#[cfg(test)]
2733mod tests {
2734    #![allow(clippy::unwrap_used, clippy::expect_used)]
2735
2736    use super::*;
2737    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
2738    use crate::porcelain::add::stage_for_commit;
2739    use crate::porcelain::commit::{create_commit, CommitRequest};
2740    use crate::progress::{NeverCancel, NullProgress};
2741    use crate::repo::InitOptions;
2742    use filetime::FileTime;
2743    use std::time::SystemTime;
2744    use tempfile::TempDir;
2745
2746    /// Pack `oids` (already stored) into one new pack of `repo`, with a `.keep` marker
2747    /// when `keep`; returns the pack path.
2748    fn pack_into(repo: &Repository, oids: &[ObjectId], keep: bool) -> PathBuf {
2749        let pack = crate::pack_objects::build_pack_from_send_list(
2750            repo.odb(),
2751            oids,
2752            &HashSet::new(),
2753            &PackBuildOptions::default(),
2754            &HashMap::new(),
2755        )
2756        .unwrap();
2757        install_pack_bytes(
2758            pack,
2759            repo.odb(),
2760            &IngestPackOptions {
2761                keep_message: keep.then(|| "pinned".to_owned()),
2762                ..IngestPackOptions::default()
2763            },
2764        )
2765        .unwrap()
2766        .pack_path
2767        .unwrap()
2768    }
2769
2770    #[test]
2771    fn promisor_rewrite_drops_only_the_omitted_objects() {
2772        let dir = TempDir::new().unwrap();
2773        let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2774        let odb = repo.odb();
2775        let big = odb.write(ObjectKind::Blob, b"big blob").unwrap();
2776        let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2777        let kept = odb.write(ObjectKind::Blob, b"kept").unwrap();
2778        let unreachable = odb.write(ObjectKind::Blob, b"unreachable loose").unwrap();
2779        let mut body = b"100644 big\0".to_vec();
2780        body.extend_from_slice(big.as_bytes());
2781        let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2782        let old_pack = pack_into(&repo, &[packed, big, tree], false);
2783        let kept_pack = pack_into(&repo, &[kept], true);
2784        crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2785        // A loose copy of an omitted object goes too.
2786        odb.write_loose_materialize(ObjectKind::Blob, b"big blob")
2787            .unwrap();
2788
2789        let omit: OidHashSet = [big, kept].into_iter().collect();
2790        let rewrite = rewrite_local_objects_without(
2791            &repo,
2792            &omit,
2793            &RepackOptions::default(),
2794            PromisorRewriteWhen::Dropping,
2795        )
2796        .unwrap();
2797        assert_eq!(rewrite.dropped, 1, "the kept copy is not ours to drop");
2798        assert_eq!(rewrite.objects, 3, "tree + packed + unreachable");
2799        let new_pack = rewrite.pack_path.expect("new pack");
2800        assert!(new_pack.with_extension("promisor").is_file());
2801        // Kept blobs share the promisor pack with the tree, so Git's repack keeps
2802        // them (it keeps only the promised objects found in promisor packs).
2803        repo.odb().reprepare_local_packs();
2804        let indexes = repo.odb().local_pack_indexes().unwrap();
2805        let new_idx = indexes
2806            .iter()
2807            .find(|i| i.pack_path == new_pack)
2808            .expect("listed");
2809        for oid in [tree, packed, unreachable] {
2810            assert!(new_idx.find_position(&oid).is_some(), "{oid}");
2811        }
2812        assert!(!old_pack.exists());
2813        assert!(kept_pack.is_file() && kept_pack.with_extension("keep").is_file());
2814        assert!(rewrite.removed_pack_files >= 2, "old .pack and .idx");
2815
2816        let repo =
2817            Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2818        let odb = repo.odb();
2819        assert!(!odb.exists_stored(&big));
2820        assert!(odb.local_loose_file_size(&big).is_none());
2821        for oid in [packed, kept, unreachable, tree] {
2822            assert!(odb.exists_stored(&oid), "{oid} kept");
2823        }
2824        assert!(odb.enumerate_local_loose_objects().unwrap().is_empty());
2825    }
2826
2827    #[test]
2828    fn promisor_rewrite_without_anything_to_drop_changes_nothing() {
2829        let dir = TempDir::new().unwrap();
2830        let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2831        let blob = repo.odb().write(ObjectKind::Blob, b"x").unwrap();
2832        let absent = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
2833        for omit in [OidHashSet::default(), [absent].into_iter().collect()] {
2834            let rewrite = rewrite_local_objects_without(
2835                &repo,
2836                &omit,
2837                &RepackOptions::default(),
2838                PromisorRewriteWhen::Dropping,
2839            )
2840            .unwrap();
2841            assert_eq!(rewrite, PromisorRewrite::default());
2842        }
2843        assert!(repo.odb().local_loose_file_size(&blob).is_some());
2844    }
2845
2846    #[test]
2847    fn promisor_rewrite_always_folds_until_one_promisor_pack_is_left() {
2848        let dir = TempDir::new().unwrap();
2849        let repo = Repository::init(dir.path(), &InitOptions::new().with_bare(true)).unwrap();
2850        let odb = repo.odb();
2851        let pushed = odb.write(ObjectKind::Blob, b"pushed").unwrap();
2852        let packed = odb.write(ObjectKind::Blob, b"packed").unwrap();
2853        let mut body = b"100644 packed\0".to_vec();
2854        body.extend_from_slice(packed.as_bytes());
2855        let tree = odb.write(ObjectKind::Tree, &body).unwrap();
2856        // A promisor pack of the tree and an ordinary pack of its blob: the layout
2857        // an earlier offload wrote, which Git's repack loses the blob from.
2858        let tree_pack = pack_into(&repo, &[tree], false);
2859        fs::write(tree_pack.with_extension("promisor"), b"").unwrap();
2860        let blob_pack = pack_into(&repo, &[packed], false);
2861        crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
2862        odb.write_loose_materialize(ObjectKind::Blob, b"pushed")
2863            .unwrap();
2864        let fold = |repo: &Repository| {
2865            rewrite_local_objects_without(
2866                repo,
2867                &OidHashSet::default(),
2868                &RepackOptions::default(),
2869                PromisorRewriteWhen::Always,
2870            )
2871            .unwrap()
2872        };
2873        let first = fold(&repo);
2874        assert_eq!(first.dropped, 0);
2875        assert_eq!(first.objects, 3);
2876        let pack = first.pack_path.expect("folded pack");
2877        assert!(pack.with_extension("promisor").is_file());
2878        assert!(!tree_pack.exists() && !blob_pack.exists());
2879        let repo =
2880            Repository::open(dir.path(), &crate::environment::RepositoryOptions::empty()).unwrap();
2881        assert!(repo
2882            .odb()
2883            .enumerate_local_loose_objects()
2884            .unwrap()
2885            .is_empty());
2886        let indexes = repo.odb().local_pack_indexes().unwrap();
2887        assert_eq!(indexes.len(), 1);
2888        for oid in [pushed, packed, tree] {
2889            assert!(indexes[0].find_position(&oid).is_some(), "{oid}");
2890        }
2891        // One promisor pack and nothing loose: nothing left to fold.
2892        assert_eq!(fold(&repo), PromisorRewrite::default());
2893        assert!(pack.is_file());
2894    }
2895
2896    #[test]
2897    fn pack_removal_spares_unlisted_and_newly_kept_packs() {
2898        let dir = TempDir::new().unwrap();
2899        for name in [
2900            "pack-a.pack",
2901            "pack-a.idx",
2902            "pack-a.rev",
2903            "pack-b.pack",
2904            "pack-b.idx",
2905            "pack-c.pack",
2906            "pack-c.keep",
2907        ] {
2908            fs::write(dir.path().join(name), b"").unwrap();
2909        }
2910        let stems: HashSet<std::ffi::OsString> =
2911            ["pack-a", "pack-c"].into_iter().map(Into::into).collect();
2912        assert_eq!(remove_pack_files_with_stems(dir.path(), &stems).unwrap(), 3);
2913        let mut left: Vec<String> = fs::read_dir(dir.path())
2914            .unwrap()
2915            .flatten()
2916            .map(|e| e.file_name().to_string_lossy().into_owned())
2917            .collect();
2918        left.sort();
2919        assert_eq!(
2920            left,
2921            ["pack-b.idx", "pack-b.pack", "pack-c.keep", "pack-c.pack"]
2922        );
2923    }
2924
2925    #[cfg_attr(
2926        not(feature = "full-tests"),
2927        ignore = "starts system git or is heavy; run with --features full-tests"
2928    )]
2929    #[test]
2930    fn gc_auto_skip_single_pack_without_loose() {
2931        let dir = TempDir::new().unwrap();
2932        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2933        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
2934        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
2935        let ident = "Test <t@example.com> 1 +0000".to_string();
2936        create_commit(
2937            &repo,
2938            &CommitRequest {
2939                message: "init".into(),
2940                author: Some(ident.parse().expect("signature")),
2941                committer: Some(ident.parse().expect("signature")),
2942                allow_empty: false,
2943                sign_override: None,
2944                amend: false,
2945                assume_worktree_synced: false,
2946                skip_pre_commit_tree_match: false,
2947                staged_change_count: None,
2948                ..Default::default()
2949            },
2950            &mut NullProgress,
2951            &NeverCancel,
2952        )
2953        .expect("commit");
2954        git_cmd(dir.path(), &["repack", "-a", "-d"]);
2955        assert!(should_auto_skip_repack(0, 1, false, true, None));
2956        assert!(!should_auto_skip_repack(3, 1, false, true, None));
2957        assert!(!should_auto_skip_repack(0, 2, false, true, None));
2958        assert!(!should_auto_skip_repack(0, 2, false, false, None));
2959    }
2960
2961    #[test]
2962    fn count_pack_files_ignores_keep_markers() {
2963        let dir = TempDir::new().unwrap();
2964        let pack_dir = dir.path().join("pack");
2965        fs::create_dir_all(&pack_dir).unwrap();
2966        fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
2967        fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
2968        fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
2969        assert_eq!(count_pack_files(&pack_dir), 1);
2970    }
2971
2972    fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
2973        let dir = TempDir::new().unwrap();
2974        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2975        let config_path = repo.git_dir().join("config");
2976        let mut config = fs::read_to_string(&config_path).unwrap();
2977        config.push_str(snippet);
2978        fs::write(&config_path, config).unwrap();
2979        repo.reload_config().expect("reload config");
2980        (dir, repo)
2981    }
2982
2983    fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
2984        let dir = repo.git_dir().join("objects").join(fanout);
2985        fs::create_dir_all(&dir).unwrap();
2986        for name in names {
2987            fs::write(dir.join(name), b"").unwrap();
2988        }
2989    }
2990
2991    const NAME_A: &str = "00000000000000000000000000000000000001";
2992    const NAME_B: &str = "00000000000000000000000000000000000002";
2993    const NAME_C: &str = "00000000000000000000000000000000000003";
2994
2995    #[test]
2996    fn auto_gc_needed_samples_one_fanout_directory() {
2997        // gc.auto = 300 allows ceil(300 / 256) = 2 sampled objects.
2998        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
2999        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3000        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3001        fake_loose(&repo, "18", &[NAME_C]);
3002        fake_loose(
3003            &repo,
3004            "17",
3005            &["tmp_obj_123", "0000000000000000000000000000000000000z"],
3006        );
3007        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3008        fake_loose(&repo, "17", &[NAME_C]);
3009        assert_eq!(
3010            auto_gc_needed(&repo).unwrap(),
3011            Some(AutoGcReason::TooManyLoose {
3012                estimate: 3 * 256,
3013                limit: 300
3014            })
3015        );
3016    }
3017
3018    #[test]
3019    fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
3020        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
3021        fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
3022        let pack_dir = repo.git_dir().join("objects/pack");
3023        fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
3024        fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
3025        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3026        assert_eq!(
3027            gc_auto(&repo, &GcOptions::default()).unwrap(),
3028            AutoGcOutcome::NotNeeded
3029        );
3030    }
3031
3032    #[test]
3033    fn auto_gc_needed_counts_packs_without_keep_markers() {
3034        let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
3035        let pack_dir = repo.git_dir().join("objects/pack");
3036        for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
3037            fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
3038        }
3039        assert_eq!(
3040            auto_gc_needed(&repo).unwrap(),
3041            Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
3042        );
3043        fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
3044        assert_eq!(auto_gc_needed(&repo).unwrap(), None);
3045    }
3046
3047    fn config_snippet(text: &str) -> ConfigSet {
3048        let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
3049        let mut set = ConfigSet::new();
3050        set.merge(&file);
3051        set
3052    }
3053
3054    #[test]
3055    fn prune_expire_now_and_all_have_no_grace_window() {
3056        let now = 1_700_000_000;
3057        for spec in ["now", "all", "NOW"] {
3058            let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3059            assert!(matches!(
3060                gc_loose_prune_policy(&cfg, now).unwrap(),
3061                GcLoosePrunePolicy::Enabled {
3062                    keep_newer_than: None
3063                }
3064            ));
3065        }
3066        for spec in ["never", "false"] {
3067            let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
3068            assert!(matches!(
3069                gc_loose_prune_policy(&cfg, now).unwrap(),
3070                GcLoosePrunePolicy::Disabled
3071            ));
3072        }
3073        let cfg = ConfigSet::new();
3074        assert!(matches!(
3075            gc_loose_prune_policy(&cfg, now).unwrap(),
3076            GcLoosePrunePolicy::Enabled {
3077                keep_newer_than: Some(_)
3078            }
3079        ));
3080    }
3081
3082    #[test]
3083    fn gc_lock_is_exclusive_and_released_on_drop() {
3084        let (_dir, repo) = repo_with_config("");
3085        let now = OffsetDateTime::now_utc().unix_timestamp();
3086        let pid_path = repo.git_dir().join("gc.pid");
3087        let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
3088            panic!("first acquire must succeed");
3089        };
3090        let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
3091        assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
3092        assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
3093        match GcLock::try_acquire(&repo, now).unwrap() {
3094            GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
3095            GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
3096        }
3097        assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
3098        assert!(matches!(
3099            gc(&repo, &GcOptions::default()),
3100            Err(Error::MaintenanceRunning { .. })
3101        ));
3102        assert!(!repo.git_dir().join("gc.pid.lock").exists());
3103        drop(lock);
3104        assert!(!pid_path.exists());
3105        assert_eq!(GcLock::current_holder(&repo, now), None);
3106        assert!(matches!(
3107            GcLock::try_acquire(&repo, now).unwrap(),
3108            GcLockAttempt::Acquired(_)
3109        ));
3110    }
3111
3112    #[test]
3113    fn gc_lock_takes_over_stale_entries() {
3114        let (_dir, repo) = repo_with_config("");
3115        let now = OffsetDateTime::now_utc().unix_timestamp();
3116        let pid_path = repo.git_dir().join("gc.pid");
3117        let host = local_host_name();
3118
3119        // A process on this host that is no longer running.
3120        fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
3121        assert!(matches!(
3122            GcLock::try_acquire(&repo, now).unwrap(),
3123            GcLockAttempt::Acquired(_)
3124        ));
3125
3126        // Another host cannot be checked, so a recent entry blocks.
3127        fs::write(&pid_path, "4242 some-other-host").unwrap();
3128        assert!(matches!(
3129            GcLock::try_acquire(&repo, now).unwrap(),
3130            GcLockAttempt::Held(GcLockHolder {
3131                pid: Some(4242),
3132                ..
3133            })
3134        ));
3135
3136        // The same entry is ignored once it is older than 12 hours.
3137        let later = now + time::Duration::hours(13).whole_seconds();
3138        assert!(matches!(
3139            GcLock::try_acquire(&repo, later).unwrap(),
3140            GcLockAttempt::Acquired(_)
3141        ));
3142    }
3143
3144    #[test]
3145    fn gc_auto_reports_held_lock_without_running() {
3146        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3147        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3148        fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
3149        let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3150        assert!(
3151            matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
3152            "{outcome:?}"
3153        );
3154        assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
3155    }
3156
3157    #[cfg(unix)]
3158    #[test]
3159    fn gc_auto_honors_failing_pre_auto_gc_hook() {
3160        use std::os::unix::fs::PermissionsExt as _;
3161        let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
3162        fake_loose(&repo, "17", &[NAME_A, NAME_B]);
3163        let hook = repo.git_dir().join("hooks/pre-auto-gc");
3164        fs::create_dir_all(hook.parent().unwrap()).unwrap();
3165        fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
3166        fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
3167        let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
3168        assert!(
3169            matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
3170            "{outcome:?}"
3171        );
3172        assert!(!repo.git_dir().join("gc.pid").exists());
3173    }
3174
3175    fn git_cmd(dir: &Path, args: &[&str]) {
3176        let status = std::process::Command::new("git")
3177            .current_dir(dir)
3178            .args(args)
3179            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3180            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3181            .status()
3182            .expect("git");
3183        assert!(status.success(), "git {args:?}");
3184    }
3185
3186    fn assert_fsck_clean(repo: &Repository) {
3187        let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
3188        let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
3189        assert!(
3190            report.ok(&repo.config().expect("config"), &opts),
3191            "{:?}",
3192            report.issues
3193        );
3194    }
3195
3196    #[test]
3197    fn fsck_clean_repo_ok() {
3198        let dir = TempDir::new().unwrap();
3199        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3200        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3201        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3202        let ident = "Test <t@example.com> 1 +0000".to_string();
3203        create_commit(
3204            &repo,
3205            &CommitRequest {
3206                message: "init".into(),
3207                author: Some(ident.parse().expect("signature")),
3208                committer: Some(ident.parse().expect("signature")),
3209                allow_empty: false,
3210                sign_override: None,
3211                amend: false,
3212                assume_worktree_synced: false,
3213                skip_pre_commit_tree_match: false,
3214                staged_change_count: None,
3215                ..Default::default()
3216            },
3217            &mut NullProgress,
3218            &NeverCancel,
3219        )
3220        .expect("commit");
3221        assert_fsck_clean(&repo);
3222    }
3223
3224    #[test]
3225    fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
3226        let dir = TempDir::new().unwrap();
3227        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3228        let wt = repo.work_tree().unwrap();
3229        std::fs::write(wt.join("base"), b"v1").unwrap();
3230        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3231        let ident = "Test <t@example.com> 1 +0000".to_string();
3232        create_commit(
3233            &repo,
3234            &CommitRequest {
3235                message: "base".into(),
3236                author: Some(ident.parse().expect("signature")),
3237                committer: Some(ident.parse().expect("signature")),
3238                allow_empty: false,
3239                sign_override: None,
3240                amend: false,
3241                assume_worktree_synced: false,
3242                skip_pre_commit_tree_match: false,
3243                staged_change_count: None,
3244                ..Default::default()
3245            },
3246            &mut NullProgress,
3247            &NeverCancel,
3248        )
3249        .expect("commit");
3250        repack_all(
3251            &repo,
3252            &RepackOptions {
3253                keep_unreachable: false,
3254                ..RepackOptions::default()
3255            },
3256        )
3257        .expect("repack base");
3258        let pack_dir = repo.git_dir().join("objects/pack");
3259        assert_eq!(count_pack_files(&pack_dir), 1);
3260
3261        std::fs::write(wt.join("child"), b"v2").unwrap();
3262        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage child");
3263        create_commit(
3264            &repo,
3265            &CommitRequest {
3266                message: "child".into(),
3267                author: Some(ident.parse().expect("signature")),
3268                committer: Some(ident.parse().expect("signature")),
3269                allow_empty: false,
3270                sign_override: None,
3271                amend: false,
3272                assume_worktree_synced: false,
3273                skip_pre_commit_tree_match: false,
3274                staged_change_count: None,
3275                ..Default::default()
3276            },
3277            &mut NullProgress,
3278            &NeverCancel,
3279        )
3280        .expect("child commit");
3281        assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3282
3283        let packs_before = count_pack_files(&pack_dir);
3284        repack_all(
3285            &repo,
3286            &RepackOptions {
3287                incremental: true,
3288                write_bitmap: true,
3289                ..RepackOptions::default()
3290            },
3291        )
3292        .expect("incremental repack with bitmap must not fail after installing a pack");
3293
3294        assert!(
3295            count_pack_files(&pack_dir) > packs_before,
3296            "incremental repack should add a pack"
3297        );
3298        let midx = pack_dir.join("multi-pack-index");
3299        assert!(
3300            midx.is_file(),
3301            "expected multi-pack-index after bitmap repack"
3302        );
3303        let midx_bytes = fs::read(&midx).expect("read midx");
3304        assert_eq!(
3305            midx_bytes.get(4),
3306            Some(&1),
3307            "MIDX on-disk version must be v1 for system git compatibility"
3308        );
3309        let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
3310            .unwrap()
3311            .filter_map(|e| e.ok())
3312            .map(|e| e.path())
3313            .filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
3314            .collect();
3315        assert!(
3316            !bitmaps.is_empty(),
3317            "expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
3318        );
3319
3320        assert_fsck_clean(&repo);
3321    }
3322
3323    #[test]
3324    fn incremental_repack_adds_pack_without_removing_base() {
3325        let dir = TempDir::new().unwrap();
3326        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3327        let wt = repo.work_tree().unwrap();
3328        std::fs::write(wt.join("base"), b"v1").unwrap();
3329        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3330        let ident = "Test <t@example.com> 1 +0000".to_string();
3331        create_commit(
3332            &repo,
3333            &CommitRequest {
3334                message: "base".into(),
3335                author: Some(ident.parse().expect("signature")),
3336                committer: Some(ident.parse().expect("signature")),
3337                allow_empty: false,
3338                sign_override: None,
3339                amend: false,
3340                assume_worktree_synced: false,
3341                skip_pre_commit_tree_match: false,
3342                staged_change_count: None,
3343                ..Default::default()
3344            },
3345            &mut NullProgress,
3346            &NeverCancel,
3347        )
3348        .expect("commit");
3349        repack_all(
3350            &repo,
3351            &RepackOptions {
3352                keep_unreachable: false,
3353                ..RepackOptions::default()
3354            },
3355        )
3356        .expect("repack");
3357        let pack_dir = repo.git_dir().join("objects/pack");
3358        let packs_before = count_pack_files(&pack_dir);
3359        assert_eq!(packs_before, 1);
3360        for i in 0..5 {
3361            create_commit(
3362                &repo,
3363                &CommitRequest {
3364                    message: format!("empty-{i}"),
3365                    author: Some(ident.parse().expect("signature")),
3366                    committer: Some(ident.parse().expect("signature")),
3367                    allow_empty: true,
3368                    sign_override: None,
3369                    amend: false,
3370                    assume_worktree_synced: false,
3371                    skip_pre_commit_tree_match: false,
3372                    staged_change_count: None,
3373                    ..Default::default()
3374                },
3375                &mut NullProgress,
3376                &NeverCancel,
3377            )
3378            .expect("empty");
3379        }
3380        assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
3381        repack_all(
3382            &repo,
3383            &RepackOptions {
3384                incremental: true,
3385                write_bitmap: false,
3386                ..RepackOptions::default()
3387            },
3388        )
3389        .expect("incremental repack");
3390        assert!(
3391            count_pack_files(&pack_dir) >= 2,
3392            "incremental repack should add a pack, not replace the base"
3393        );
3394    }
3395
3396    #[test]
3397    fn gc_full_run_records_per_phase_timings() {
3398        let dir = TempDir::new().unwrap();
3399        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
3400        let wt = repo.work_tree().unwrap();
3401        for i in 0..5 {
3402            std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
3403            stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3404            let ident = "Test <t@example.com> 1 +0000".to_string();
3405            create_commit(
3406                &repo,
3407                &CommitRequest {
3408                    message: format!("c{i}"),
3409                    author: Some(ident.parse().expect("signature")),
3410                    committer: Some(ident.parse().expect("signature")),
3411                    allow_empty: false,
3412                    sign_override: None,
3413                    amend: false,
3414                    assume_worktree_synced: false,
3415                    skip_pre_commit_tree_match: false,
3416                    staged_change_count: None,
3417                    ..Default::default()
3418                },
3419                &mut NullProgress,
3420                &NeverCancel,
3421            )
3422            .expect("commit");
3423        }
3424        let stats = gc(
3425            &repo,
3426            &GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
3427        )
3428        .expect("gc");
3429        let t = stats.phase_timings;
3430        assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
3431        assert!(
3432            t.repack_ms > 0,
3433            "repack phase must be timed separately from full gc"
3434        );
3435        assert!(stats.pack_refs.is_some());
3436        assert!(stats.commit_graph.is_some());
3437        assert_eq!(
3438            t.prune_ms, stats.repack.unreachable_prune_ms,
3439            "prune_ms must track unreachable loose prune inside repack, not repack_ms"
3440        );
3441        // Cached ref/config paths can finish pack-refs or commit-graph in under 1 ms (0 in
3442        // millisecond buckets). Require repack timing plus evidence the other phases ran.
3443        assert!(
3444            t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
3445            "full gc must time repack and run pack-refs + commit-graph, got {t:?}"
3446        );
3447        assert!(stats.prune_ran, "full gc should run unreachable prune");
3448    }
3449
3450    #[cfg_attr(
3451        not(feature = "full-tests"),
3452        ignore = "starts system git or is heavy; run with --features full-tests"
3453    )]
3454    #[test]
3455    fn full_repack_keeps_migrated_pack_before_refs_updated() {
3456        use crate::receive_quarantine::ReceiveQuarantine;
3457
3458        let tmp = tempfile::tempdir().expect("tempdir");
3459        let remote = tmp.path().join("remote.git");
3460        let wt = tmp.path().join("wt");
3461        std::fs::create_dir_all(&wt).unwrap();
3462        std::fs::create_dir_all(&remote).unwrap();
3463        let git = |dir: &Path, args: &[&str]| {
3464            assert!(std::process::Command::new("git")
3465                .current_dir(dir)
3466                .args(args)
3467                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3468                .env("GIT_CONFIG_SYSTEM", "/dev/null")
3469                .status()
3470                .expect("git")
3471                .success());
3472        };
3473        git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3474        git(&wt, &["init", "-q", "-b", "main"]);
3475        std::fs::write(wt.join("payload.txt"), b"payload\n").unwrap();
3476        git(&wt, &["add", "payload.txt"]);
3477        git(&wt, &["commit", "-qm", "base"]);
3478        git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3479        git(&remote, &["repack", "-a", "-d"]);
3480        let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3481            .expect("open remote");
3482        let pack_dir = repo.git_dir().join("objects/pack");
3483        let base_pack = super::local_pack_paths(&pack_dir)
3484            .into_iter()
3485            .next()
3486            .expect("base pack");
3487        let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3488        let q_root = PathBuf::from(
3489            q.hook_env()
3490                .into_iter()
3491                .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3492                .map(|(_, v)| v)
3493                .expect("quarantine path"),
3494        );
3495        let q_pack_dir = q_root.join("pack");
3496        fs::create_dir_all(&q_pack_dir).unwrap();
3497        for ext in ["pack", "idx", "rev"] {
3498            let src = base_pack.with_extension(ext);
3499            if src.is_file() {
3500                fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3501            }
3502        }
3503        let guards = q.migrate().expect("migrate");
3504        assert!(
3505            super::local_pack_paths(&pack_dir).len() >= 2,
3506            "migrated push pack must be visible before gc inventories packs"
3507        );
3508        repack_all(
3509            &repo,
3510            &RepackOptions {
3511                auto_skip: false,
3512                write_bitmap: false,
3513                ..RepackOptions::default()
3514            },
3515        )
3516        .expect("repack");
3517        assert!(
3518            pack_dir.join("pack-incoming.pack").is_file(),
3519            "incoming pack must survive full repack while .keep guards are held"
3520        );
3521        drop(guards);
3522    }
3523
3524    #[test]
3525    fn full_repack_drops_unprotected_migrated_pack_before_refs_updated() {
3526        use crate::receive_quarantine::ReceiveQuarantine;
3527
3528        let tmp = tempfile::tempdir().expect("tempdir");
3529        let remote = tmp.path().join("remote.git");
3530        let wt = tmp.path().join("wt");
3531        std::fs::create_dir_all(&wt).unwrap();
3532        std::fs::create_dir_all(&remote).unwrap();
3533        let git = |dir: &Path, args: &[&str]| {
3534            assert!(std::process::Command::new("git")
3535                .current_dir(dir)
3536                .args(args)
3537                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3538                .env("GIT_CONFIG_SYSTEM", "/dev/null")
3539                .status()
3540                .expect("git")
3541                .success());
3542        };
3543        git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3544        git(&wt, &["init", "-q", "-b", "main"]);
3545        git(&wt, &["commit", "--allow-empty", "-qm", "base"]);
3546        git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3547        git(&remote, &["repack", "-a", "-d"]);
3548        let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3549            .expect("open");
3550        let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3551        let orphan = q
3552            .odb()
3553            .write(crate::objects::ObjectKind::Blob, b"orphan-bytes")
3554            .expect("write in quarantine");
3555        drop(q.migrate().expect("migrate"));
3556        repack_all(
3557            &repo,
3558            &RepackOptions {
3559                auto_skip: false,
3560                write_bitmap: false,
3561                keep_unreachable: false,
3562                ..RepackOptions::default()
3563            },
3564        )
3565        .expect("repack");
3566        assert!(
3567            !repo.odb().exists(&orphan),
3568            "without .keep protection gc may drop an unreachable migrated loose object"
3569        );
3570    }
3571
3572    #[cfg_attr(
3573        not(feature = "full-tests"),
3574        ignore = "starts system git or is heavy; run with --features full-tests"
3575    )]
3576    #[test]
3577    fn repack_retains_inventory_kept_pack_after_ref_publish_and_guard_drop() {
3578        use crate::receive_quarantine::ReceiveQuarantine;
3579        use std::process::Command;
3580        use std::sync::{Arc, Mutex};
3581
3582        let tmp = tempfile::tempdir().expect("tempdir");
3583        let remote = tmp.path().join("remote.git");
3584        let wt = tmp.path().join("wt");
3585        std::fs::create_dir_all(&wt).unwrap();
3586        std::fs::create_dir_all(&remote).unwrap();
3587        let git = |dir: &Path, args: &[&str]| {
3588            assert!(Command::new("git")
3589                .current_dir(dir)
3590                .args(args)
3591                .env("GIT_CONFIG_GLOBAL", "/dev/null")
3592                .env("GIT_CONFIG_SYSTEM", "/dev/null")
3593                .status()
3594                .expect("git")
3595                .success());
3596        };
3597        git(&remote, &["init", "-q", "--bare", "-b", "main"]);
3598        git(&wt, &["init", "-q", "-b", "main"]);
3599        std::fs::write(wt.join("payload.txt"), b"v1\n").unwrap();
3600        git(&wt, &["add", "payload.txt"]);
3601        git(&wt, &["commit", "-qm", "base"]);
3602        git(&wt, &["push", "-q", remote.to_str().unwrap(), "main"]);
3603        git(&remote, &["repack", "-a", "-d"]);
3604        std::fs::write(wt.join("payload.txt"), b"v2\n").unwrap();
3605        git(&wt, &["add", "payload.txt"]);
3606        git(&wt, &["commit", "-qm", "incoming"]);
3607        git(&wt, &["repack", "-a", "-d"]);
3608        let incoming_hex = String::from_utf8(
3609            Command::new("git")
3610                .current_dir(&wt)
3611                .args(["rev-parse", "HEAD"])
3612                .output()
3613                .expect("rev-parse")
3614                .stdout,
3615        )
3616        .expect("utf8")
3617        .trim()
3618        .to_owned();
3619        let incoming = ObjectId::from_hex(&incoming_hex).expect("incoming tip");
3620
3621        let repo = Repository::open(&remote, &crate::environment::RepositoryOptions::empty())
3622            .expect("open remote");
3623        let mut q = ReceiveQuarantine::create(repo.odb(), repo.git_dir()).expect("quarantine");
3624        let q_root = PathBuf::from(
3625            q.hook_env()
3626                .into_iter()
3627                .find(|(k, _)| k == "GIT_QUARANTINE_PATH")
3628                .map(|(_, v)| v)
3629                .expect("quarantine path"),
3630        );
3631        let q_pack_dir = q_root.join("pack");
3632        fs::create_dir_all(&q_pack_dir).unwrap();
3633        let wt_pack_dir = wt.join(".git/objects/pack");
3634        let wt_pack = fs::read_dir(&wt_pack_dir)
3635            .expect("wt pack dir")
3636            .filter_map(|e| e.ok())
3637            .map(|e| e.path())
3638            .find(|p| p.extension().is_some_and(|ext| ext == "pack"))
3639            .expect("wt repack pack");
3640        for ext in ["pack", "idx", "rev"] {
3641            let src = wt_pack.with_extension(ext);
3642            if src.is_file() {
3643                fs::copy(&src, q_pack_dir.join(format!("pack-incoming.{ext}"))).unwrap();
3644            }
3645        }
3646        assert!(
3647            q_pack_dir.join("pack-incoming.pack").is_file(),
3648            "incoming pack sidecar"
3649        );
3650
3651        let pack_dir = repo.git_dir().join("objects/pack");
3652        let incoming_keep = pack_dir.join("pack-incoming.keep");
3653        let guards_slot = Arc::new(Mutex::new(None));
3654        let guards = q.migrate().expect("migrate");
3655        *guards_slot.lock().expect("lock") = Some(guards);
3656        assert!(incoming_keep.is_file());
3657
3658        let remote_for_hook = remote.clone();
3659        let incoming_for_hook = incoming_hex.clone();
3660        let incoming_keep_for_hook = incoming_keep.clone();
3661        let guards_for_hook = Arc::clone(&guards_slot);
3662        super::gc_test_hooks::on_next_gc(
3663            super::gc_test_hooks::GcPausePoint::AfterRootsBeforeDropAll,
3664            move || {
3665                assert!(Command::new("git")
3666                    .current_dir(&remote_for_hook)
3667                    .args(["update-ref", "refs/heads/main", &incoming_for_hook,])
3668                    .env("GIT_CONFIG_GLOBAL", "/dev/null")
3669                    .env("GIT_CONFIG_SYSTEM", "/dev/null")
3670                    .status()
3671                    .expect("update-ref")
3672                    .success());
3673                drop(guards_for_hook.lock().expect("lock").take());
3674                assert!(
3675                    !incoming_keep_for_hook.exists(),
3676                    "receive path drops live keep markers after publication"
3677                );
3678            },
3679        );
3680
3681        repack_all(
3682            &repo,
3683            &RepackOptions {
3684                auto_skip: false,
3685                write_bitmap: false,
3686                ..RepackOptions::default()
3687            },
3688        )
3689        .expect("repack");
3690
3691        assert!(
3692            repo.odb().exists(&incoming),
3693            "inventory-time keep must protect the pack after live markers are dropped"
3694        );
3695        assert!(Command::new("git")
3696            .current_dir(&remote)
3697            .args(["fsck", "--strict"])
3698            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3699            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3700            .status()
3701            .expect("fsck")
3702            .success());
3703    }
3704
3705    #[test]
3706    fn pack_install_stage_name_matches_only_grit_staging_dirs() {
3707        assert!(is_grit_pack_install_stage_name(
3708            ".pack-deadbeef-install-1_0"
3709        ));
3710        assert!(!is_grit_pack_install_stage_name(".notes-install-backup"));
3711        assert!(!is_grit_pack_install_stage_name(".cache-install-state"));
3712        assert!(is_grit_pack_install_stage_name(
3713            ".pack-deadbeef-install-stale-file"
3714        ));
3715    }
3716
3717    #[test]
3718    fn gc_prunes_stale_tmp_pack_and_install_dirs() {
3719        let (_dir, repo) = repo_with_config("");
3720        let pack_dir = repo.git_dir().join("objects/pack");
3721        fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3722        fs::write(pack_dir.join("tmp_pack_fresh"), b"new").unwrap();
3723        fs::create_dir(pack_dir.join(".pack-deadbeef-install-9_0")).unwrap();
3724        fs::write(pack_dir.join(".notes-install-backup"), b"notes").unwrap();
3725        fs::create_dir(pack_dir.join(".cache-install-state")).unwrap();
3726        fs::write(
3727            pack_dir.join(".pack-deadbeef-install-stale-file"),
3728            b"not a stage dir",
3729        )
3730        .unwrap();
3731        let cutoff = SystemTime::now() - std::time::Duration::from_secs(21 * 86400);
3732        filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3733        filetime::set_file_mtime(
3734            pack_dir.join(".pack-deadbeef-install-9_0"),
3735            FileTime::from(cutoff),
3736        )
3737        .unwrap();
3738        for path in [
3739            pack_dir.join(".notes-install-backup"),
3740            pack_dir.join(".cache-install-state"),
3741            pack_dir.join(".pack-deadbeef-install-stale-file"),
3742        ] {
3743            filetime::set_file_mtime(&path, FileTime::from(cutoff)).unwrap();
3744        }
3745        gc(&repo, &GcOptions::default()).expect("gc");
3746        assert!(!pack_dir.join("tmp_pack_old").exists());
3747        assert!(!pack_dir.join(".pack-deadbeef-install-9_0").exists());
3748        assert!(pack_dir.join("tmp_pack_fresh").exists());
3749        assert!(pack_dir.join(".notes-install-backup").exists());
3750        assert!(pack_dir.join(".cache-install-state").is_dir());
3751        assert!(pack_dir.join(".pack-deadbeef-install-stale-file").is_file());
3752
3753        fs::write(pack_dir.join("tmp_pack_old"), b"old").unwrap();
3754        filetime::set_file_mtime(pack_dir.join("tmp_pack_old"), FileTime::from(cutoff)).unwrap();
3755        let stats = gc(
3756            &repo,
3757            &GcOptions {
3758                repack: RepackOptions {
3759                    dry_run: true,
3760                    ..RepackOptions::default()
3761                },
3762                ..GcOptions::default()
3763            },
3764        )
3765        .expect("dry-run gc");
3766        assert!(stats.prune.pruned >= 1);
3767        assert!(pack_dir.join("tmp_pack_old").exists());
3768    }
3769
3770    #[test]
3771    fn gc_reaps_stale_next_index_files_in_git_and_linked_worktree() {
3772        use crate::worktree::{add_linked_worktree, AddLinkedWorktreeOptions, WorktreeBranch};
3773
3774        let (dir, repo) = repo_with_config("");
3775        std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
3776        stage_for_commit(&repo, &mut NullProgress, &NeverCancel).expect("stage");
3777        let ident = "Test <t@example.com> 1 +0000".to_string();
3778        create_commit(
3779            &repo,
3780            &CommitRequest {
3781                message: "init".into(),
3782                author: Some(ident.parse().expect("signature")),
3783                committer: Some(ident.parse().expect("signature")),
3784                allow_empty: false,
3785                sign_override: None,
3786                amend: false,
3787                assume_worktree_synced: false,
3788                skip_pre_commit_tree_match: false,
3789                staged_change_count: None,
3790                ..Default::default()
3791            },
3792            &mut NullProgress,
3793            &NeverCancel,
3794        )
3795        .expect("commit");
3796        let main_git = repo.git_dir().to_path_buf();
3797        let dead_pid = 99_999_997_u64;
3798        let live_pid = u64::from(std::process::id());
3799        fs::write(
3800            main_git.join(format!("next-index-{dead_pid}")),
3801            b"main orphan",
3802        )
3803        .unwrap();
3804        fs::write(
3805            main_git.join(format!("next-index-{live_pid}")),
3806            b"main live",
3807        )
3808        .unwrap();
3809
3810        let linked_path = dir.path().join("linked-wt");
3811        add_linked_worktree(
3812            &repo,
3813            &AddLinkedWorktreeOptions {
3814                path: linked_path.clone(),
3815                branch: Some(WorktreeBranch::Create("linked".into())),
3816                start_point: Some("HEAD".into()),
3817                force: false,
3818                lock: None,
3819                no_checkout: true,
3820                track: false,
3821            },
3822        )
3823        .expect("linked worktree");
3824        let linked_git = crate::worktree::list_worktrees(&repo)
3825            .unwrap()
3826            .into_iter()
3827            .find(|e| e.path == linked_path)
3828            .expect("linked entry")
3829            .admin_dir;
3830        fs::write(
3831            linked_git.join(format!("next-index-{dead_pid}")),
3832            b"linked orphan",
3833        )
3834        .unwrap();
3835
3836        gc(&repo, &GcOptions::default()).expect("gc");
3837        assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3838        assert!(main_git.join(format!("next-index-{live_pid}")).exists());
3839        assert!(!linked_git.join(format!("next-index-{dead_pid}")).exists());
3840
3841        fs::write(main_git.join(format!("next-index-{dead_pid}")), b"again").unwrap();
3842        gc(
3843            &repo,
3844            &GcOptions {
3845                repack: RepackOptions {
3846                    dry_run: true,
3847                    ..RepackOptions::default()
3848                },
3849                ..GcOptions::default()
3850            },
3851        )
3852        .expect("dry-run gc");
3853        assert!(main_git.join(format!("next-index-{dead_pid}")).exists());
3854        gc(&repo, &GcOptions::default()).expect("gc again");
3855        assert!(!main_git.join(format!("next-index-{dead_pid}")).exists());
3856    }
3857}