Skip to main content

grit_lib/
maintenance.rs

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