use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime};
use crate::commit_graph_file::CommitGraphChain;
use crate::commit_graph_write::{
atomic_replace_commit_graph, build_commit_graph_bytes_from_oid_maps,
collect_reachable_commit_oids_for_repository, load_commit_graph_commit_info,
remove_commit_graph_chain_dir, remove_commit_graph_files, BloomWriteStats,
};
use crate::config::ConfigSet;
use crate::diff::bloom::BloomFilterSettings;
use crate::environment::Environment;
use crate::error::{ConfigError, Error, Result};
use crate::fsck as repo_fsck;
use crate::gc::{prune_loose_unreachable_with, LazyReachable, PruneStats};
use crate::git_date::approx::approxidate_careful_at;
use crate::index_pack::{install_pack_bytes, install_pack_path, IngestPackOptions};
use crate::midx::{
clear_pack_midx_state, midx_version_from_config, midx_write_version,
write_multi_pack_index_with_options, MidxBitmapWriteOutcome, WriteMultiPackIndexOptions,
WriteMultiPackIndexResult,
};
use crate::objects::{
oid_hash_map, parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind, OidHashSet,
};
use crate::odb::Odb;
use crate::odb::UnreachablePackRetention;
use crate::pack_bitmap::{PackBitmapWriteOptions, PackBitmapWriter};
use crate::pack_delta::{pack_delta_search_parallel_options, pack_write_parallel_options};
use crate::pack_object_select::{
enumerate_pack_objects_naming, NameTrees, PackEnumerateOptions, PackObjectSelection,
};
use crate::pack_objects::{
build_pack_from_send_list_with_parallel_threads, PackBuildOptions, SerializedDeltaPack,
};
use crate::pack_rev::write_pack_rev_sidecar_for_index_path;
use crate::prune_packed::{prune_packed_objects, PrunePackedOptions};
use crate::reflog::{
expire_reflog_git, list_reflog_refs, load_gc_reflog_expire_config, ReflogExpireParams,
};
use crate::repack_pack::{write_repack_pack, RepackPackSettings};
use crate::repo::Repository;
use crate::shallow::load_shallow_boundaries;
use time::OffsetDateTime;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RepackOptions {
pub dry_run: bool,
pub write_commit_graph: bool,
pub write_bitmap: bool,
pub prune_older_than: Option<OffsetDateTime>,
pub now: Option<OffsetDateTime>,
pub keep_unreachable: bool,
pub threads: Option<usize>,
pub pack_build: Option<PackBuildOptions>,
pub merged_config: Option<ConfigSet>,
pub environment: Environment,
pub incremental: bool,
pub auto_skip: bool,
pub force_recompress: bool,
}
impl Default for RepackOptions {
fn default() -> Self {
Self {
dry_run: false,
write_commit_graph: false,
write_bitmap: false,
prune_older_than: None,
now: None,
keep_unreachable: true,
threads: None,
pack_build: None,
merged_config: None,
environment: Environment::empty(),
incremental: false,
auto_skip: false,
force_recompress: false,
}
}
}
impl RepackOptions {
#[must_use]
pub fn with_dry_run(dry_run: bool) -> Self {
Self {
dry_run,
..Self::default()
}
}
}
struct RepackDetail {
report: RepackReport,
prune_packed_loose: usize,
unreachable_loose: PruneStats,
unreachable_prune_ms: u64,
unreachable_prune_ran: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RepackReport {
pub packs_written: usize,
pub packs_removed: usize,
pub loose_removed: usize,
pub objects: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RepackStats {
pub loose_before: usize,
pub packs_before: usize,
pub objects_packed: Option<usize>,
pub pack_bytes: Option<u64>,
pub pack_path: Option<PathBuf>,
pub removed_pack_files: usize,
pub pruned_loose: usize,
pub unreachable_loose_pruned: usize,
pub unreachable_loose_kept: usize,
pub unreachable_prune_ms: u64,
pub unreachable_prune_ran: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommitGraphOptions {
pub changed_paths: bool,
pub dry_run: bool,
}
impl CommitGraphOptions {
#[must_use]
pub fn with_changed_paths(changed_paths: bool) -> Self {
Self {
changed_paths,
..Self::default()
}
}
#[must_use]
pub fn with_dry_run(dry_run: bool) -> Self {
Self {
dry_run,
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommitGraphWriteReport {
pub path: PathBuf,
pub commit_count: usize,
pub removed_chain: bool,
pub bloom_stats: Option<BloomWriteStats>,
pub skipped: Option<CommitGraphSkipReason>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommitGraphSkipReason {
Shallow,
Grafts,
ReplaceRefs,
}
impl CommitGraphSkipReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Shallow => "shallow",
Self::Grafts => "grafts",
Self::ReplaceRefs => "replace-refs",
}
}
pub(crate) fn detect(
git_dir: &Path,
has_replace_refs: impl FnOnce() -> Result<bool>,
) -> Result<Option<Self>> {
let common = crate::repo::common_git_dir_for_config(git_dir);
if !crate::shallow::load_shallow_boundaries(&common).is_empty() {
return Ok(Some(Self::Shallow));
}
if !crate::rev_parse::load_graft_parents(&common).is_empty() {
return Ok(Some(Self::Grafts));
}
Ok(has_replace_refs()?.then_some(Self::ReplaceRefs))
}
pub(crate) fn detect_for_repository(repo: &Repository) -> Result<Option<Self>> {
Self::detect(repo.git_dir(), || {
let Some(base) = repo.replace_ref_base_if_enabled() else {
return Ok(false);
};
Ok(!repo.references().list(&base)?.is_empty())
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct MidxOptions {
pub bitmap: bool,
pub dry_run: bool,
pub version: Option<u8>,
}
impl MidxOptions {
#[must_use]
pub fn with_bitmap(bitmap: bool) -> Self {
Self {
bitmap,
..Self::default()
}
}
#[must_use]
pub fn with_dry_run(dry_run: bool) -> Self {
Self {
dry_run,
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MidxWriteReport {
pub path: PathBuf,
pub pack_count: usize,
pub bitmap: MidxBitmapWriteOutcome,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct PackRefsOptions {
pub all: bool,
pub dry_run: bool,
}
impl PackRefsOptions {
#[must_use]
pub fn with_all(all: bool) -> Self {
Self {
all,
..Self::default()
}
}
#[must_use]
pub fn with_dry_run(dry_run: bool) -> Self {
Self {
dry_run,
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PackRefsReport {
pub path: PathBuf,
pub refs_packed: usize,
pub refs_pruned: usize,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GcOptions {
pub repack: RepackOptions,
pub expire_reflog: bool,
pub now_unix: i64,
pub pack_refs: bool,
pub write_commit_graph: bool,
}
impl Default for GcOptions {
fn default() -> Self {
Self {
repack: RepackOptions {
auto_skip: true,
..RepackOptions::default()
},
expire_reflog: true,
now_unix: 0,
pack_refs: true,
write_commit_graph: true,
}
}
}
impl GcOptions {
#[must_use]
pub fn with_repack_dry_run(dry_run: bool, now_unix: i64) -> Self {
Self {
repack: RepackOptions {
dry_run,
auto_skip: true,
..RepackOptions::default()
},
expire_reflog: true,
now_unix,
..Self::default()
}
}
#[must_use]
pub fn with_reflog_policy(now_unix: i64, expire_reflog: bool) -> Self {
Self {
repack: RepackOptions {
auto_skip: true,
..RepackOptions::default()
},
expire_reflog,
now_unix,
..Self::default()
}
}
#[must_use]
pub fn full_run(dry_run: bool, now_unix: i64) -> Self {
Self {
repack: RepackOptions {
dry_run,
auto_skip: true,
..RepackOptions::default()
},
expire_reflog: true,
now_unix,
pack_refs: true,
write_commit_graph: true,
..Self::default()
}
}
}
impl PruneOptions {
#[must_use]
pub fn with_older_than(older_than: Option<SystemTime>) -> Self {
Self {
older_than,
..Self::default()
}
}
}
pub fn collect_maintenance_object_roots(repo: &Repository) -> Result<Vec<ObjectId>> {
repo_fsck::reachability_roots(repo)
}
pub(crate) fn bloom_settings_for_commit_graph_write(
config: &ConfigSet,
changed_paths: bool,
) -> BloomFilterSettings {
let mut bloom = BloomFilterSettings::default();
if !changed_paths {
return bloom;
}
if let Some(v) = config
.get("commitgraph.changedpathsversion")
.and_then(|s| s.trim().parse::<i32>().ok())
{
if v == 2 {
bloom.hash_version = 2;
} else if v == 1 {
bloom.hash_version = 1;
}
}
bloom
}
fn existing_commit_graph_has_changed_paths(repo: &Repository) -> bool {
repo.odb()
.files_objects_dir()
.and_then(|dir| CommitGraphChain::try_load(dir).ok().flatten())
.is_some_and(|chain| chain.top_layer_bloom_settings().is_some())
}
pub(crate) fn commit_graph_writes_generation_data(config: &ConfigSet) -> bool {
config
.get("commitgraph.generationversion")
.and_then(|s| s.trim().parse::<i32>().ok())
.map(|v| v >= 2)
.unwrap_or(true)
}
pub fn write_commit_graph(
repo: &Repository,
opts: &CommitGraphOptions,
) -> Result<CommitGraphWriteReport> {
let odb = repo.odb();
odb.require_files_primary("commit-graph write")?;
let objects_dir = odb
.files_objects_dir()
.ok_or(Error::UnsupportedObjectStore {
operation: "commit-graph write",
})?;
let path = objects_dir.join("info").join("commit-graph");
if let Some(reason) = CommitGraphSkipReason::detect_for_repository(repo)? {
let removed_chain = if opts.dry_run {
false
} else {
let removed = remove_commit_graph_files(objects_dir)?;
repo.caches().invalidate_commit_graph_cache();
removed
};
return Ok(CommitGraphWriteReport {
path,
commit_count: 0,
removed_chain,
bloom_stats: None,
skipped: Some(reason),
});
}
let config = repo.config()?;
let bloom = bloom_settings_for_commit_graph_write(&config, opts.changed_paths);
let write_generation = commit_graph_writes_generation_data(&config);
let chain = CommitGraphChain::try_load(objects_dir).ok().flatten();
let commits = collect_reachable_commit_oids_for_repository(repo)?;
let mut sorted: Vec<ObjectId> = commits.into_iter().collect();
sorted.sort();
let commit_count = sorted.len();
if opts.dry_run {
return Ok(CommitGraphWriteReport {
path,
commit_count,
removed_chain: false,
bloom_stats: opts.changed_paths.then_some(BloomWriteStats::default()),
skipped: None,
});
}
let mut infos = oid_hash_map();
for oid in &sorted {
infos.insert(*oid, load_commit_graph_commit_info(odb, *oid)?);
}
let mut existing_filters = oid_hash_map();
let mut upgraded_filters = oid_hash_map();
if opts.changed_paths {
if let Some(ref chain) = chain {
for oid in &sorted {
if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
existing_filters.insert(*oid, bytes);
} else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
upgraded_filters.insert(*oid, bytes);
}
}
}
}
let (bytes, bloom_stats) = build_commit_graph_bytes_from_oid_maps(
&sorted,
&infos,
odb,
opts.changed_paths,
&bloom,
None,
&[],
None,
&existing_filters,
&upgraded_filters,
write_generation,
)?;
let written_path = atomic_replace_commit_graph(objects_dir, &bytes)?;
let removed_chain = remove_commit_graph_chain_dir(objects_dir);
repo.caches().invalidate_commit_graph_cache();
Ok(CommitGraphWriteReport {
path: written_path,
commit_count,
removed_chain,
bloom_stats: opts.changed_paths.then_some(bloom_stats),
skipped: None,
})
}
pub fn write_midx(repo: &Repository, opts: &MidxOptions) -> Result<MidxWriteReport> {
let odb = repo.odb();
odb.require_files_primary("midx write")?;
let requested = match opts.version {
Some(v) => Some(v),
None => midx_version_from_config(&*repo.config()?)?,
};
let version = Some(midx_write_version(requested)?);
let objects_dir = odb
.files_objects_dir()
.ok_or(Error::UnsupportedObjectStore {
operation: "midx write",
})?;
let pack_dir = objects_dir.join("pack");
let pack_count = fs::read_dir(&pack_dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "idx"))
.filter(|e| e.path().with_extension("pack").is_file())
.count()
})
.unwrap_or(0);
let path = pack_dir.join("multi-pack-index");
if opts.dry_run {
return Ok(MidxWriteReport {
path,
pack_count,
bitmap: MidxBitmapWriteOutcome::NotRequested,
});
}
let write_opts = WriteMultiPackIndexOptions {
write_bitmap: opts.bitmap,
version,
..WriteMultiPackIndexOptions::default()
};
let WriteMultiPackIndexResult { bitmap } =
write_multi_pack_index_with_options(&pack_dir, &write_opts)?;
Ok(MidxWriteReport {
path,
pack_count,
bitmap,
})
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct PruneOptions {
pub older_than: Option<SystemTime>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct GcPhaseTimings {
pub pack_refs_ms: u64,
pub reflog_ms: u64,
pub repack_ms: u64,
pub prune_ms: u64,
pub commit_graph_ms: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GcStats {
pub pack_refs: Option<PackRefsReport>,
pub repack: RepackStats,
pub prune: PruneStats,
pub prune_ran: bool,
pub commit_graph: Option<CommitGraphWriteReport>,
pub reflog_entries_pruned: usize,
pub phase_timings: GcPhaseTimings,
}
pub fn repack(repo: &Repository, opts: &RepackOptions) -> Result<RepackReport> {
Ok(repack_detail(repo, opts)?.report)
}
fn repack_detail(repo: &Repository, opts: &RepackOptions) -> Result<RepackDetail> {
let odb = repo.odb();
odb.require_files_primary("repack")?;
let objects_dir = odb
.files_objects_dir()
.ok_or(Error::UnsupportedObjectStore {
operation: "repack",
})?;
let pack_dir = objects_dir.join("pack");
let mut report = RepackReport::default();
let loose_before = count_loose_objects(odb)?;
let packs_before = count_pack_files(&pack_dir);
let roots = collect_maintenance_object_roots(repo)?;
if roots.is_empty() && loose_before == 0 && packs_before == 0 {
return Ok(RepackDetail {
report,
prune_packed_loose: 0,
unreachable_loose: PruneStats::default(),
unreachable_prune_ms: 0,
unreachable_prune_ran: !opts.keep_unreachable && !opts.dry_run,
});
}
let cfg = opts
.merged_config
.clone()
.or_else(|| repo.config().ok().map(|c| (*c).clone()));
let mut pack_opts = opts
.pack_build
.unwrap_or_else(|| PackBuildOptions::for_local_copy(cfg.as_ref()));
if opts.force_recompress {
pack_opts.reuse_deltas = false;
}
let run_incremental = opts.incremental;
let shallow = load_shallow_boundaries(repo.git_dir());
let reachable = LazyReachable::new(odb, &roots, &shallow);
let skip_repack = !opts.force_recompress
&& !opts.write_bitmap
&& !opts.write_commit_graph
&& should_auto_skip_repack(
loose_before,
packs_before,
run_incremental,
opts.auto_skip,
cfg.as_ref(),
)
&& !skipping_repack_would_retain_prunable_packed_objects(odb, &pack_dir, &reachable, opts)?;
if opts.dry_run {
report.objects = if skip_repack {
0
} else if run_incremental {
reachable_loose_objects(odb, &reachable)?.len()
} else {
estimate_repack_object_count(
odb,
&roots,
&shallow,
opts.keep_unreachable,
pack_opts.use_bitmaps,
)?
};
return Ok(RepackDetail {
report,
prune_packed_loose: 0,
unreachable_loose: PruneStats::default(),
unreachable_prune_ms: 0,
unreachable_prune_ran: false,
});
}
let built_pack = if skip_repack {
SerializedDeltaPack {
bytes: Vec::new(),
index_rows: Vec::new(),
staging_path: None,
}
} else if run_incremental {
SerializedDeltaPack {
bytes: pack_loose_objects_incremental(odb, &reachable, &pack_opts, opts.threads)?,
index_rows: Vec::new(),
staging_path: None,
}
} else {
build_repack_pack(
odb,
&pack_dir,
&roots,
&shallow,
opts.keep_unreachable,
&pack_opts,
opts.threads,
cfg.as_ref(),
)?
};
let pack_bytes_len = built_pack
.staging_path
.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len() as usize)
.unwrap_or_else(|| built_pack.bytes.len());
if pack_bytes_len == 0 && !run_incremental {
return Ok(RepackDetail {
report,
prune_packed_loose: 0,
unreachable_loose: PruneStats::default(),
unreachable_prune_ms: 0,
unreachable_prune_ran: false,
});
}
let mut prune_packed_loose = 0usize;
let mut unreachable_loose = PruneStats::default();
let pack_trailer_len = 12 + odb.hash_algo().len();
if pack_bytes_len > pack_trailer_len {
clear_pack_midx_state(&pack_dir)?;
let trusted_rows = (!built_pack.index_rows.is_empty()).then_some(built_pack.index_rows);
let ingest_opts = IngestPackOptions {
fix_thin: false,
threads: opts.threads,
merged_config: cfg.clone(),
environment: opts.environment.clone(),
skip_post_index_verify: trusted_rows.is_some(),
trusted_index_rows: trusted_rows,
..IngestPackOptions::default()
};
let ingested = if let Some(staging) = built_pack.staging_path.as_ref() {
install_pack_path(staging, odb, &ingest_opts)?
} else {
install_pack_bytes(built_pack.bytes, odb, &ingest_opts)?
};
report.packs_written = 1;
report.objects = ingested.object_ids.len();
let pack_path = ingested.pack_path.ok_or(Error::UnsupportedObjectStore {
operation: "repack",
})?;
let idx_path = pack_path.with_extension("idx");
if cfg
.as_ref()
.is_none_or(|c| c.pack_write_reverse_index_default(&opts.environment))
{
write_pack_rev_sidecar_for_index_path(&idx_path)?;
}
if opts.write_bitmap {
if opts.incremental && packs_before > 0 {
let version = match cfg.as_ref() {
Some(c) => midx_version_from_config(c)?,
None => None,
};
let midx_opts = WriteMultiPackIndexOptions {
write_bitmap: true,
version,
..WriteMultiPackIndexOptions::default()
};
let WriteMultiPackIndexResult { bitmap: _ } =
write_multi_pack_index_with_options(&pack_dir, &midx_opts)?;
} else {
let bitmap_opts = PackBitmapWriteOptions::default();
let now = opts
.now
.map(offset_to_system_time)
.unwrap_or(SystemTime::UNIX_EPOCH);
PackBitmapWriter::write(repo, &idx_path, &bitmap_opts, now)
.map_err(|e| Error::CorruptObject(e.to_string()))?;
}
}
let retention = if opts.keep_unreachable {
UnreachablePackRetention::KeepAll
} else if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
UnreachablePackRetention::KeepGraceProtected(cutoff)
} else {
UnreachablePackRetention::DropAll
};
if !opts.incremental {
odb.loosen_unreachable_from_superseded_packs(
&pack_dir, &pack_path, &reachable, retention,
)?;
}
odb.invalidate_packs();
report.packs_removed = if opts.incremental {
0
} else {
remove_superseded_pack_files(&pack_dir, &pack_path)?
};
prune_packed_loose =
prune_packed_objects(objects_dir, PrunePackedOptions::default())?.len();
if opts.write_commit_graph {
write_commit_graph(
repo,
&CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(
repo,
)),
)?;
}
}
let mut unreachable_prune_ms = 0u64;
let mut unreachable_prune_ran = false;
if !opts.keep_unreachable {
let keep_newer_than = opts.prune_older_than.map(offset_to_system_time);
let prune_start = Instant::now();
unreachable_loose = prune_loose_unreachable_with(odb, &reachable, keep_newer_than, false)?;
unreachable_prune_ms = prune_start.elapsed().as_millis() as u64;
unreachable_prune_ran = true;
}
report.loose_removed = prune_packed_loose + unreachable_loose.pruned;
Ok(RepackDetail {
report,
prune_packed_loose,
unreachable_loose,
unreachable_prune_ms,
unreachable_prune_ran,
})
}
pub fn repack_all(repo: &Repository, opts: &RepackOptions) -> Result<RepackStats> {
let odb = repo.odb();
let objects_dir = odb
.files_objects_dir()
.ok_or(Error::UnsupportedObjectStore {
operation: "repack",
})?;
let pack_dir = objects_dir.join("pack");
let mut stats = RepackStats {
loose_before: count_loose_objects(odb)?,
packs_before: count_pack_files(&pack_dir),
..RepackStats::default()
};
if opts.dry_run {
let count = repack_detail(repo, opts)?.report.objects;
stats.objects_packed = (count > 0).then_some(count);
return Ok(stats);
}
let detail = repack_detail(repo, opts)?;
let report = detail.report;
stats.objects_packed = (report.packs_written > 0).then_some(report.objects);
stats.removed_pack_files = report.packs_removed;
stats.pruned_loose = detail.prune_packed_loose;
stats.unreachable_loose_pruned = detail.unreachable_loose.pruned;
stats.unreachable_loose_kept = detail.unreachable_loose.kept;
stats.unreachable_prune_ms = detail.unreachable_prune_ms;
stats.unreachable_prune_ran = detail.unreachable_prune_ran;
if report.packs_written > 0 {
let packs: Vec<_> = fs::read_dir(&pack_dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.map(|e| e.path())
.collect();
if packs.len() == 1 {
stats.pack_path = Some(packs[0].clone());
stats.pack_bytes = packs[0].metadata().ok().map(|m| m.len());
}
}
Ok(stats)
}
fn offset_to_system_time(odt: OffsetDateTime) -> SystemTime {
SystemTime::UNIX_EPOCH
+ std::time::Duration::new(odt.unix_timestamp().max(0) as u64, odt.nanosecond())
}
fn apply_gc_prune_policy_to_repack(repack: &mut RepackOptions, policy: &GcLoosePrunePolicy) {
match policy {
GcLoosePrunePolicy::Disabled => {
repack.keep_unreachable = true;
}
GcLoosePrunePolicy::Enabled { keep_newer_than } => {
repack.keep_unreachable = false;
repack.prune_older_than = keep_newer_than.map(|t| {
let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
OffsetDateTime::from_unix_timestamp(dur.as_secs() as i64)
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
});
}
}
}
fn gc_repack_writes_bitmap(repo: &Repository, config: &ConfigSet) -> bool {
if let Some(Ok(v)) = config.get_bool("repack.writeBitmaps") {
return v;
}
repo.work_tree().is_none()
}
fn reachable_loose_objects(odb: &Odb, reachable: &LazyReachable<'_>) -> Result<Vec<ObjectId>> {
let loose = odb.enumerate_local_loose_objects()?;
if loose.is_empty() {
return Ok(Vec::new());
}
let reachable = reachable.get()?;
Ok(loose
.into_iter()
.map(|(oid, _)| oid)
.filter(|oid| reachable.contains(oid))
.collect())
}
fn pack_loose_objects_incremental(
odb: &Odb,
reachable: &LazyReachable<'_>,
pack_opts: &PackBuildOptions,
threads: Option<usize>,
) -> Result<Vec<u8>> {
let loose = reachable_loose_objects(odb, reachable)?;
if loose.is_empty() {
return Ok(Vec::new());
}
let mut incremental_opts = *pack_opts;
incremental_opts.delta = false;
incremental_opts.reuse_deltas = false;
incremental_opts.use_bitmaps = false;
build_pack_from_send_list_with_parallel_threads(
odb,
&loose,
&HashSet::new(),
&incremental_opts,
&HashMap::new(),
None,
threads,
)
}
fn should_auto_skip_repack(
loose_objects: usize,
pack_count: usize,
incremental: bool,
auto_skip: bool,
_config: Option<&ConfigSet>,
) -> bool {
if !auto_skip || incremental {
return false;
}
if pack_count > 1 {
return false;
}
if pack_count == 1 {
return loose_objects == 0;
}
loose_objects == 0
}
fn skipping_repack_would_retain_prunable_packed_objects(
odb: &Odb,
pack_dir: &Path,
reachable: &LazyReachable<'_>,
opts: &RepackOptions,
) -> Result<bool> {
if opts.keep_unreachable {
return Ok(false);
}
let retention = if let Some(cutoff) = opts.prune_older_than.map(offset_to_system_time) {
UnreachablePackRetention::KeepGraceProtected(cutoff)
} else {
UnreachablePackRetention::DropAll
};
let indexes = crate::pack::read_local_pack_indexes_cached(odb.objects_dir())?;
for idx in indexes {
if pack_has_keep_marker(pack_dir, &idx.pack_path) {
continue;
}
let pack_mtime = fs::metadata(&idx.pack_path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let drop_unreachable_from_pack = match retention {
UnreachablePackRetention::KeepAll => false,
UnreachablePackRetention::DropAll => true,
UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime < cutoff,
};
if !drop_unreachable_from_pack {
continue;
}
let reachable = reachable.get()?;
for entry in idx.iter() {
let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
continue;
};
if !reachable.contains(&oid) {
return Ok(true);
}
}
}
Ok(false)
}
pub fn prune_loose_objects(repo: &Repository, opts: &PruneOptions) -> Result<PruneStats> {
let odb = repo.odb();
odb.require_files_primary("prune")?;
let cfg = repo.config()?;
let now = gc_now_unix(0);
let keep_newer_than = match opts.older_than {
Some(t) => Some(t),
None => match gc_loose_prune_policy(&cfg, now)? {
GcLoosePrunePolicy::Disabled => return Ok(PruneStats::default()),
GcLoosePrunePolicy::Enabled { keep_newer_than } => keep_newer_than,
},
};
let roots = collect_maintenance_object_roots(repo)?;
let shallow = load_shallow_boundaries(repo.git_dir());
prune_loose_unreachable_with(
odb,
&LazyReachable::new(odb, &roots, &shallow),
keep_newer_than,
opts.dry_run,
)
}
fn duration_ms(d: std::time::Duration) -> u64 {
d.as_millis() as u64
}
pub fn gc(repo: &Repository, opts: &GcOptions) -> Result<GcStats> {
repo.odb().require_files_primary("gc")?;
let now = gc_now_unix(opts.now_unix);
let _lock = if opts.repack.dry_run {
None
} else {
match GcLock::try_acquire(repo, now)? {
GcLockAttempt::Acquired(lock) => Some(lock),
GcLockAttempt::Held(holder) => return Err(Error::MaintenanceRunning { holder }),
}
};
gc_locked(repo, opts, now)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoGcReason {
TooManyLoose {
estimate: usize,
limit: usize,
},
TooManyPacks {
count: usize,
limit: usize,
},
}
const AUTO_GC_SAMPLE_FANOUT: &str = "17";
pub fn auto_gc_needed(repo: &Repository) -> Result<Option<AutoGcReason>> {
let cfg = repo.config()?;
let Some(threshold) = cfg.gc_auto_threshold() else {
return Ok(None);
};
let Some(objects_dir) = repo.odb().files_objects_dir() else {
return Ok(None);
};
if let Some(limit) = cfg.gc_auto_pack_limit() {
let count = count_pack_files(&objects_dir.join("pack"));
if count > limit {
return Ok(Some(AutoGcReason::TooManyPacks { count, limit }));
}
}
let name_len = repo.odb().hash_algo().hex_len() - 2;
let sampled = count_loose_in_fanout(&objects_dir.join(AUTO_GC_SAMPLE_FANOUT), name_len)?;
if sampled > threshold.div_ceil(256) {
return Ok(Some(AutoGcReason::TooManyLoose {
estimate: sampled.saturating_mul(256),
limit: threshold,
}));
}
Ok(None)
}
fn count_loose_in_fanout(dir: &Path, name_len: usize) -> Result<usize> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(Error::Io(e)),
};
Ok(entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.as_encoded_bytes();
name.len() == name_len && name.iter().all(u8::is_ascii_hexdigit)
})
.count())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AutoGcOutcome {
NotNeeded,
AlreadyRunning(GcLockHolder),
DeclinedByHook {
status: i32,
output: crate::hooks::HookOutput,
},
Ran {
reason: AutoGcReason,
stats: Box<GcStats>,
},
}
pub fn gc_auto(repo: &Repository, opts: &GcOptions) -> Result<AutoGcOutcome> {
let Some(reason) = auto_gc_needed(repo)? else {
return Ok(AutoGcOutcome::NotNeeded);
};
let now = gc_now_unix(opts.now_unix);
let dry_run = opts.repack.dry_run;
let _lock = if dry_run {
None
} else {
match GcLock::try_acquire(repo, now)? {
GcLockAttempt::Acquired(lock) => Some(lock),
GcLockAttempt::Held(holder) => return Ok(AutoGcOutcome::AlreadyRunning(holder)),
}
};
if !dry_run {
if let crate::hooks::HookResult::Failed { status, output } =
crate::hooks::run_hook(repo, "pre-auto-gc", &[], None)?
{
return Ok(AutoGcOutcome::DeclinedByHook { status, output });
}
}
let mut run_opts = opts.clone();
if matches!(reason, AutoGcReason::TooManyLoose { .. }) {
run_opts.repack.incremental = true;
run_opts.repack.write_bitmap = false;
}
let stats = gc_locked(repo, &run_opts, now)?;
Ok(AutoGcOutcome::Ran {
reason,
stats: Box::new(stats),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GcLockHolder {
pub pid: Option<u64>,
pub host: Option<String>,
}
#[derive(Debug)]
#[must_use]
pub enum GcLockAttempt {
Acquired(GcLock),
Held(GcLockHolder),
}
const GC_PID_STALE_AFTER: time::Duration = time::Duration::hours(12);
#[derive(Debug)]
pub struct GcLock {
path: PathBuf,
}
impl GcLock {
pub fn try_acquire(repo: &Repository, now_unix: i64) -> Result<GcLockAttempt> {
let path = repo.common_git_dir().join("gc.pid");
let mut lock = match crate::lockfile::LockFile::acquire(
&path.with_extension("pid.lock"),
crate::lockfile::LockWait::Immediately,
) {
Ok(lock) => lock,
Err(crate::lockfile::LockError::Held { .. }) => {
return Ok(GcLockAttempt::Held(read_gc_pid(&path).unwrap_or(
GcLockHolder {
pid: None,
host: None,
},
)));
}
Err(crate::lockfile::LockError::Io { source, .. }) => return Err(Error::Io(source)),
};
let host = local_host_name();
if let Some(holder) = live_gc_pid_holder(&path, &host, now_unix) {
lock.rollback();
return Ok(GcLockAttempt::Held(holder));
}
use std::io::Write as _;
write!(lock, "{} {host}", std::process::id())?;
lock.commit_to(&path).map_err(|e| match e {
crate::lockfile::LockError::Io { source, .. } => Error::Io(source),
held @ crate::lockfile::LockError::Held { .. } => Error::Io(io::Error::other(held)),
})?;
Ok(GcLockAttempt::Acquired(GcLock { path }))
}
#[must_use]
pub fn current_holder(repo: &Repository, now_unix: i64) -> Option<GcLockHolder> {
let path = repo.common_git_dir().join("gc.pid");
live_gc_pid_holder(&path, &local_host_name(), now_unix)
}
}
impl Drop for GcLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn read_gc_pid(path: &Path) -> Option<GcLockHolder> {
let raw = fs::read_to_string(path).ok()?;
let mut fields = raw.split_whitespace();
let pid = fields.next().and_then(|p| p.parse::<u64>().ok());
let host = fields.next().map(str::to_owned);
Some(GcLockHolder { pid, host })
}
fn live_gc_pid_holder(path: &Path, host: &str, now_unix: i64) -> Option<GcLockHolder> {
let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
let mtime = OffsetDateTime::from(mtime);
let now = OffsetDateTime::from_unix_timestamp(now_unix).ok()?;
if now - mtime > GC_PID_STALE_AFTER {
return None;
}
let holder = read_gc_pid(path)?;
let pid = holder.pid?;
let same_host = holder.host.as_deref() == Some(host);
if same_host && !crate::index::is_process_running(pid) {
return None;
}
Some(holder)
}
fn local_host_name() -> String {
#[cfg(unix)]
{
if let Ok(uts) = nix::sys::utsname::uname() {
if let Some(name) = uts.nodename().to_str() {
return name.to_owned();
}
}
}
#[cfg(windows)]
{
if let Some(name) = windows_dns_host_name() {
return name;
}
}
"localhost".to_owned()
}
#[cfg(windows)]
fn windows_dns_host_name() -> Option<String> {
use windows_sys::Win32::System::SystemInformation::{
ComputerNameDnsHostname, GetComputerNameExW,
};
let mut len: u32 = 0;
unsafe { GetComputerNameExW(ComputerNameDnsHostname, std::ptr::null_mut(), &mut len) };
if len == 0 {
return None;
}
let mut buf = vec![0u16; len as usize];
let ok = unsafe { GetComputerNameExW(ComputerNameDnsHostname, buf.as_mut_ptr(), &mut len) };
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
}
fn gc_locked(repo: &Repository, opts: &GcOptions, now: i64) -> Result<GcStats> {
let mut stats = GcStats::default();
let cfg = repo.config()?;
let prune_policy = gc_loose_prune_policy(&cfg, now)?;
let dry_run = opts.repack.dry_run;
if opts.pack_refs && !dry_run {
let start = Instant::now();
stats.pack_refs = Some(pack_refs(repo, &PackRefsOptions::with_all(true))?);
stats.phase_timings.pack_refs_ms = duration_ms(start.elapsed());
}
if opts.expire_reflog {
let start = Instant::now();
stats.reflog_entries_pruned = expire_reflogs_for_gc(repo, dry_run, now)?;
stats.phase_timings.reflog_ms = duration_ms(start.elapsed());
}
let mut repack_opts = opts.repack.clone();
apply_gc_prune_policy_to_repack(&mut repack_opts, &prune_policy);
if repack_opts.now.is_none() {
repack_opts.now =
Some(OffsetDateTime::from_unix_timestamp(now).unwrap_or(OffsetDateTime::UNIX_EPOCH));
}
if !repack_opts.write_bitmap && !repack_opts.incremental {
repack_opts.write_bitmap = gc_repack_writes_bitmap(repo, &cfg);
}
let repack_start = Instant::now();
stats.repack = repack_all(repo, &repack_opts)?;
let repack_total_ms = duration_ms(repack_start.elapsed());
stats.phase_timings.prune_ms = stats.repack.unreachable_prune_ms;
stats.phase_timings.repack_ms = repack_total_ms.saturating_sub(stats.phase_timings.prune_ms);
if dry_run {
return Ok(stats);
}
stats.prune_ran = stats.repack.unreachable_prune_ran;
stats.prune = PruneStats {
pruned: stats.repack.unreachable_loose_pruned,
kept: stats.repack.unreachable_loose_kept,
};
if opts.write_commit_graph {
let start = Instant::now();
stats.commit_graph = Some(write_commit_graph(
repo,
&CommitGraphOptions::with_changed_paths(existing_commit_graph_has_changed_paths(repo)),
)?);
stats.phase_timings.commit_graph_ms = duration_ms(start.elapsed());
}
Ok(stats)
}
enum GcLoosePrunePolicy {
Disabled,
Enabled {
keep_newer_than: Option<SystemTime>,
},
}
fn gc_loose_prune_policy(config: &ConfigSet, now_unix: i64) -> Result<GcLoosePrunePolicy> {
let spec = config
.get("gc.pruneExpire")
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "2.weeks.ago".to_owned());
if spec.eq_ignore_ascii_case("never") || spec.eq_ignore_ascii_case("false") {
return Ok(GcLoosePrunePolicy::Disabled);
}
if spec.eq_ignore_ascii_case("now") || spec.eq_ignore_ascii_case("all") {
return Ok(GcLoosePrunePolicy::Enabled {
keep_newer_than: None,
});
}
let mut err = 0;
let cutoff_sec = approxidate_careful_at(&spec, now_unix, Some(&mut err)) as i64;
if err != 0 {
return Err(Error::Config(ConfigError::Other(format!(
"bad config value '{spec}' for 'gc.pruneExpire'"
))));
}
let keep_newer_than = SystemTime::UNIX_EPOCH
.checked_add(std::time::Duration::from_secs(cutoff_sec.max(0) as u64));
Ok(GcLoosePrunePolicy::Enabled { keep_newer_than })
}
fn gc_now_unix(now_unix: i64) -> i64 {
if now_unix != 0 {
return now_unix;
}
time::OffsetDateTime::now_utc().unix_timestamp()
}
fn expire_reflogs_for_gc(repo: &Repository, dry_run: bool, now: i64) -> Result<usize> {
let cfg = repo.config()?;
let gc_cfg = load_gc_reflog_expire_config(&cfg, now);
let git_dir = repo.git_dir();
let params = ReflogExpireParams {
stale_fix: false,
dry_run,
verbose: false,
};
let mut total = 0usize;
for refname in list_reflog_refs(git_dir)? {
let result = expire_reflog_git(
repo,
git_dir,
&refname,
¶ms,
None,
None,
&gc_cfg.patterns,
gc_cfg.global_total,
gc_cfg.global_unreachable,
now,
)?;
total += result.pruned;
}
Ok(total)
}
fn count_loose_objects(odb: &Odb) -> Result<usize> {
Ok(odb.enumerate_local_loose_objects()?.len())
}
#[allow(clippy::too_many_arguments)]
fn build_repack_pack(
odb: &Odb,
pack_dir: &Path,
roots: &[ObjectId],
shallow: &HashSet<ObjectId>,
keep_unreachable: bool,
opts: &PackBuildOptions,
threads: Option<usize>,
cfg: Option<&ConfigSet>,
) -> Result<SerializedDeltaPack> {
let selection = repack_object_list(odb, roots, shallow, keep_unreachable, opts.use_bitmaps)?;
odb.clear_delta_base_cache();
let max_depth = if opts.delta { opts.max_depth } else { 0 };
let settings = RepackPackSettings {
window: opts.window,
max_depth,
reuse_deltas: opts.reuse_deltas,
reuse_objects: opts.reuse_objects,
use_ofs_delta: opts.use_ofs_delta,
window_memory: cfg.map_or(0, ConfigSet::pack_window_memory_bytes),
search_threads: pack_delta_search_parallel_options(odb, threads)?
.threads
.get(),
write_threads: pack_write_parallel_options(odb, threads)?.threads.get(),
};
fs::create_dir_all(pack_dir).map_err(Error::Io)?;
let staging = pack_dir.join(format!("tmp_repack_{}", std::process::id()));
let written = write_repack_pack(
odb,
&selection.send,
&selection.name_hashes,
&settings,
&staging,
);
match written {
Ok(index_rows) => Ok(SerializedDeltaPack {
bytes: Vec::new(),
index_rows,
staging_path: Some(staging),
}),
Err(err) => {
let _ = fs::remove_file(&staging);
Err(err)
}
}
}
fn repack_object_list(
odb: &Odb,
roots: &[ObjectId],
shallow: &HashSet<ObjectId>,
keep_unreachable: bool,
use_bitmaps: bool,
) -> Result<PackObjectSelection> {
let mut roots = roots.to_vec();
roots.sort_unstable();
let mut selection = enumerate_pack_objects_naming(
odb,
&roots,
&[],
&PackEnumerateOptions {
filter: None,
shallow_grafts: shallow,
have_shallow_grafts: Some(shallow),
use_bitmaps,
exclude_objects: None,
peer_odb: None,
},
NameTrees::Yes,
)?;
if keep_unreachable {
append_repack_unreachable_retention(odb, &mut selection.send)?;
}
Ok(selection)
}
fn append_repack_unreachable_retention(odb: &Odb, send: &mut Vec<ObjectId>) -> Result<()> {
let mut in_send: OidHashSet = send.iter().copied().collect();
odb.primary()?
.packed_objects()
.for_each_removable_pack_object(&mut |oid| {
if in_send.insert(*oid) {
send.push(*oid);
}
std::ops::ControlFlow::Continue(())
})?;
for (oid, _) in odb.enumerate_local_loose_objects()? {
if in_send.insert(oid) {
send.push(oid);
}
}
Ok(())
}
fn estimate_repack_object_count(
odb: &Odb,
roots: &[ObjectId],
shallow: &HashSet<ObjectId>,
keep_unreachable: bool,
use_bitmaps: bool,
) -> Result<usize> {
if !keep_unreachable {
return estimate_reachable_count(odb, roots, shallow);
}
Ok(repack_object_list(odb, roots, shallow, true, use_bitmaps)?
.send
.len())
}
fn count_pack_files(pack_dir: &Path) -> usize {
let Ok(entries) = fs::read_dir(pack_dir) else {
return 0;
};
let mut packs = HashSet::new();
let mut kept = HashSet::new();
for entry in entries.filter_map(|e| e.ok()) {
let path = PathBuf::from(entry.file_name());
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension()) else {
continue;
};
if ext == "pack" {
packs.insert(stem.to_owned());
} else if ext == "keep" {
kept.insert(stem.to_owned());
}
}
packs.difference(&kept).count()
}
fn estimate_reachable_count(
odb: &Odb,
roots: &[ObjectId],
shallow: &HashSet<ObjectId>,
) -> Result<usize> {
let mut seen: HashSet<ObjectId> = HashSet::new();
let mut queue: VecDeque<ObjectId> = VecDeque::new();
for &root in roots {
if seen.insert(root) {
queue.push_back(root);
}
}
while let Some(oid) = queue.pop_front() {
let obj = match odb.read(&oid) {
Ok(o) => o,
Err(_) => continue,
};
match obj.kind {
ObjectKind::Commit => {
let commit = parse_commit(&obj.data)?;
if !shallow.contains(&oid) {
for parent in commit.parents {
if seen.insert(parent) {
queue.push_back(parent);
}
}
}
if seen.insert(commit.tree) {
queue.push_back(commit.tree);
}
}
ObjectKind::Tree => {
for entry in parse_tree(&obj.data)? {
if entry.mode == 0o160000 {
continue;
}
if seen.insert(entry.oid) {
queue.push_back(entry.oid);
}
}
}
ObjectKind::Tag => {
let tag = parse_tag(&obj.data)?;
if seen.insert(tag.object) {
queue.push_back(tag.object);
}
}
ObjectKind::Blob => {}
}
}
Ok(seen.len())
}
pub fn remove_superseded_pack_files(pack_dir: &Path, keep_pack: &Path) -> Result<usize> {
let keep_stem = keep_pack
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| Error::Io(io::Error::new(io::ErrorKind::InvalidInput, "bad pack path")))?;
if !pack_dir.is_dir() {
return Ok(0);
}
let mut removed = 0usize;
for entry in fs::read_dir(pack_dir).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
continue;
}
if !(name_str.starts_with("pack-") || name_str.starts_with(".pack-")) {
continue;
}
let path = entry.path();
if path.is_file() {
if pack_has_keep_marker(pack_dir, &path) {
continue;
}
fs::remove_file(&path).map_err(Error::Io)?;
removed += 1;
}
}
Ok(removed)
}
fn pack_has_keep_marker(pack_dir: &Path, path: &Path) -> bool {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
return false;
};
pack_dir.join(format!("{stem}.keep")).is_file()
}
pub fn pack_refs(repo: &Repository, opts: &PackRefsOptions) -> Result<PackRefsReport> {
let (path, refs_packed, refs_pruned) =
crate::refs::pack_refs_for_repository(repo, opts.all, opts.dry_run)?;
Ok(PackRefsReport {
path,
refs_packed,
refs_pruned,
})
}
impl Repository {
pub fn write_commit_graph(&self, opts: &CommitGraphOptions) -> Result<CommitGraphWriteReport> {
write_commit_graph(self, opts)
}
pub fn write_midx(&self, opts: &MidxOptions) -> Result<MidxWriteReport> {
write_midx(self, opts)
}
}
pub mod fsck {
pub use crate::fsck_standalone::*;
}
pub mod repository_fsck {
pub use crate::fsck::*;
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::config::{ConfigFile, ConfigScope, ConfigSet};
use crate::porcelain::add::stage_for_commit;
use crate::porcelain::commit::{create_commit, CommitRequest, HookPolicy};
use crate::progress::{NeverCancel, NullProgress};
use crate::repo::InitOptions;
use tempfile::TempDir;
#[test]
fn gc_auto_skip_single_pack_without_loose() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage");
let ident = "Test <t@example.com> 1 +0000".to_string();
create_commit(
&repo,
&CommitRequest {
message: "init".into(),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("commit");
git_cmd(dir.path(), &["repack", "-a", "-d"]);
assert!(should_auto_skip_repack(0, 1, false, true, None));
assert!(!should_auto_skip_repack(3, 1, false, true, None));
assert!(!should_auto_skip_repack(0, 2, false, true, None));
assert!(!should_auto_skip_repack(0, 2, false, false, None));
}
#[test]
fn count_pack_files_ignores_keep_markers() {
let dir = TempDir::new().unwrap();
let pack_dir = dir.path().join("pack");
fs::create_dir_all(&pack_dir).unwrap();
fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
fs::write(pack_dir.join("pack-bbb.keep"), b"").unwrap();
assert_eq!(count_pack_files(&pack_dir), 1);
}
fn repo_with_config(snippet: &str) -> (TempDir, Repository) {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
let config_path = repo.git_dir().join("config");
let mut config = fs::read_to_string(&config_path).unwrap();
config.push_str(snippet);
fs::write(&config_path, config).unwrap();
repo.reload_config().expect("reload config");
(dir, repo)
}
fn fake_loose(repo: &Repository, fanout: &str, names: &[&str]) {
let dir = repo.git_dir().join("objects").join(fanout);
fs::create_dir_all(&dir).unwrap();
for name in names {
fs::write(dir.join(name), b"").unwrap();
}
}
const NAME_A: &str = "00000000000000000000000000000000000001";
const NAME_B: &str = "00000000000000000000000000000000000002";
const NAME_C: &str = "00000000000000000000000000000000000003";
#[test]
fn auto_gc_needed_samples_one_fanout_directory() {
let (_dir, repo) = repo_with_config("[gc]\n\tauto = 300\n");
assert_eq!(auto_gc_needed(&repo).unwrap(), None);
fake_loose(&repo, "17", &[NAME_A, NAME_B]);
fake_loose(&repo, "18", &[NAME_C]);
fake_loose(
&repo,
"17",
&["tmp_obj_123", "0000000000000000000000000000000000000z"],
);
assert_eq!(auto_gc_needed(&repo).unwrap(), None);
fake_loose(&repo, "17", &[NAME_C]);
assert_eq!(
auto_gc_needed(&repo).unwrap(),
Some(AutoGcReason::TooManyLoose {
estimate: 3 * 256,
limit: 300
})
);
}
#[test]
fn auto_gc_needed_is_off_when_gc_auto_is_zero() {
let (_dir, repo) = repo_with_config("[gc]\n\tauto = 0\n\tautoPackLimit = 1\n");
fake_loose(&repo, "17", &[NAME_A, NAME_B, NAME_C]);
let pack_dir = repo.git_dir().join("objects/pack");
fs::write(pack_dir.join("pack-aaa.pack"), b"PACK").unwrap();
fs::write(pack_dir.join("pack-bbb.pack"), b"PACK").unwrap();
assert_eq!(auto_gc_needed(&repo).unwrap(), None);
assert_eq!(
gc_auto(&repo, &GcOptions::default()).unwrap(),
AutoGcOutcome::NotNeeded
);
}
#[test]
fn auto_gc_needed_counts_packs_without_keep_markers() {
let (_dir, repo) = repo_with_config("[gc]\n\tautoPackLimit = 2\n");
let pack_dir = repo.git_dir().join("objects/pack");
for stem in ["pack-aaa", "pack-bbb", "pack-ccc"] {
fs::write(pack_dir.join(format!("{stem}.pack")), b"PACK").unwrap();
}
assert_eq!(
auto_gc_needed(&repo).unwrap(),
Some(AutoGcReason::TooManyPacks { count: 3, limit: 2 })
);
fs::write(pack_dir.join("pack-ccc.keep"), b"").unwrap();
assert_eq!(auto_gc_needed(&repo).unwrap(), None);
}
fn config_snippet(text: &str) -> ConfigSet {
let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local).unwrap();
let mut set = ConfigSet::new();
set.merge(&file);
set
}
#[test]
fn prune_expire_now_and_all_have_no_grace_window() {
let now = 1_700_000_000;
for spec in ["now", "all", "NOW"] {
let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
assert!(matches!(
gc_loose_prune_policy(&cfg, now).unwrap(),
GcLoosePrunePolicy::Enabled {
keep_newer_than: None
}
));
}
for spec in ["never", "false"] {
let cfg = config_snippet(&format!("[gc]\n\tpruneExpire = {spec}\n"));
assert!(matches!(
gc_loose_prune_policy(&cfg, now).unwrap(),
GcLoosePrunePolicy::Disabled
));
}
let cfg = ConfigSet::new();
assert!(matches!(
gc_loose_prune_policy(&cfg, now).unwrap(),
GcLoosePrunePolicy::Enabled {
keep_newer_than: Some(_)
}
));
}
#[test]
fn gc_lock_is_exclusive_and_released_on_drop() {
let (_dir, repo) = repo_with_config("");
let now = OffsetDateTime::now_utc().unix_timestamp();
let pid_path = repo.git_dir().join("gc.pid");
let GcLockAttempt::Acquired(lock) = GcLock::try_acquire(&repo, now).unwrap() else {
panic!("first acquire must succeed");
};
let recorded = read_gc_pid(&pid_path).expect("gc.pid written");
assert_eq!(recorded.pid, Some(u64::from(std::process::id())));
assert_eq!(recorded.host.as_deref(), Some(local_host_name().as_str()));
match GcLock::try_acquire(&repo, now).unwrap() {
GcLockAttempt::Held(holder) => assert_eq!(holder, recorded),
GcLockAttempt::Acquired(_) => panic!("second acquire must see the live holder"),
}
assert_eq!(GcLock::current_holder(&repo, now), Some(recorded));
assert!(matches!(
gc(&repo, &GcOptions::default()),
Err(Error::MaintenanceRunning { .. })
));
assert!(!repo.git_dir().join("gc.pid.lock").exists());
drop(lock);
assert!(!pid_path.exists());
assert_eq!(GcLock::current_holder(&repo, now), None);
assert!(matches!(
GcLock::try_acquire(&repo, now).unwrap(),
GcLockAttempt::Acquired(_)
));
}
#[test]
fn gc_lock_takes_over_stale_entries() {
let (_dir, repo) = repo_with_config("");
let now = OffsetDateTime::now_utc().unix_timestamp();
let pid_path = repo.git_dir().join("gc.pid");
let host = local_host_name();
fs::write(&pid_path, format!("{} {host}", i32::MAX)).unwrap();
assert!(matches!(
GcLock::try_acquire(&repo, now).unwrap(),
GcLockAttempt::Acquired(_)
));
fs::write(&pid_path, "4242 some-other-host").unwrap();
assert!(matches!(
GcLock::try_acquire(&repo, now).unwrap(),
GcLockAttempt::Held(GcLockHolder {
pid: Some(4242),
..
})
));
let later = now + time::Duration::hours(13).whole_seconds();
assert!(matches!(
GcLock::try_acquire(&repo, later).unwrap(),
GcLockAttempt::Acquired(_)
));
}
#[test]
fn gc_auto_reports_held_lock_without_running() {
let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
fake_loose(&repo, "17", &[NAME_A, NAME_B]);
fs::write(repo.git_dir().join("gc.pid"), "4242 some-other-host").unwrap();
let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
assert!(
matches!(&outcome, AutoGcOutcome::AlreadyRunning(holder) if holder.pid == Some(4242)),
"{outcome:?}"
);
assert!(repo.git_dir().join("objects/17").join(NAME_A).exists());
}
#[cfg(unix)]
#[test]
fn gc_auto_honors_failing_pre_auto_gc_hook() {
use std::os::unix::fs::PermissionsExt as _;
let (_dir, repo) = repo_with_config("[gc]\n\tauto = 1\n");
fake_loose(&repo, "17", &[NAME_A, NAME_B]);
let hook = repo.git_dir().join("hooks/pre-auto-gc");
fs::create_dir_all(hook.parent().unwrap()).unwrap();
fs::write(&hook, "#!/bin/sh\nexit 3\n").unwrap();
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap();
let outcome = gc_auto(&repo, &GcOptions::default()).unwrap();
assert!(
matches!(outcome, AutoGcOutcome::DeclinedByHook { status: 3, .. }),
"{outcome:?}"
);
assert!(!repo.git_dir().join("gc.pid").exists());
}
fn git_cmd(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git");
assert!(status.success(), "git {args:?}");
}
fn assert_fsck_clean(repo: &Repository) {
let opts = repo_fsck::FsckOptions::full_repository(true).with_check_dangling(false);
let report = repo_fsck::fsck(repo, &opts, &mut NullProgress, &NeverCancel).expect("fsck");
assert!(
report.ok(&repo.config().expect("config"), &opts),
"{:?}",
report.issues
);
}
#[test]
fn fsck_clean_repo_ok() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
std::fs::write(repo.work_tree().unwrap().join("a"), b"x").unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage");
let ident = "Test <t@example.com> 1 +0000".to_string();
create_commit(
&repo,
&CommitRequest {
message: "init".into(),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("commit");
assert_fsck_clean(&repo);
}
#[test]
fn incremental_repack_with_bitmap_keeps_base_pack_and_writes_midx_bitmap() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
let wt = repo.work_tree().unwrap();
std::fs::write(wt.join("base"), b"v1").unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage");
let ident = "Test <t@example.com> 1 +0000".to_string();
create_commit(
&repo,
&CommitRequest {
message: "base".into(),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("commit");
repack_all(
&repo,
&RepackOptions {
keep_unreachable: false,
..RepackOptions::default()
},
)
.expect("repack base");
let pack_dir = repo.git_dir().join("objects/pack");
assert_eq!(count_pack_files(&pack_dir), 1);
std::fs::write(wt.join("child"), b"v2").unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage child");
create_commit(
&repo,
&CommitRequest {
message: "child".into(),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("child commit");
assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
let packs_before = count_pack_files(&pack_dir);
repack_all(
&repo,
&RepackOptions {
incremental: true,
write_bitmap: true,
..RepackOptions::default()
},
)
.expect("incremental repack with bitmap must not fail after installing a pack");
assert!(
count_pack_files(&pack_dir) > packs_before,
"incremental repack should add a pack"
);
let midx = pack_dir.join("multi-pack-index");
assert!(
midx.is_file(),
"expected multi-pack-index after bitmap repack"
);
let midx_bytes = fs::read(&midx).expect("read midx");
assert_eq!(
midx_bytes.get(4),
Some(&1),
"MIDX on-disk version must be v1 for system git compatibility"
);
let bitmaps: Vec<_> = fs::read_dir(&pack_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "bitmap"))
.collect();
assert!(
!bitmaps.is_empty(),
"expected a MIDX or pack bitmap sidecar after incremental repack with write_bitmap"
);
assert_fsck_clean(&repo);
}
#[test]
fn incremental_repack_adds_pack_without_removing_base() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
let wt = repo.work_tree().unwrap();
std::fs::write(wt.join("base"), b"v1").unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage");
let ident = "Test <t@example.com> 1 +0000".to_string();
create_commit(
&repo,
&CommitRequest {
message: "base".into(),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("commit");
repack_all(
&repo,
&RepackOptions {
keep_unreachable: false,
..RepackOptions::default()
},
)
.expect("repack");
let pack_dir = repo.git_dir().join("objects/pack");
let packs_before = count_pack_files(&pack_dir);
assert_eq!(packs_before, 1);
for i in 0..5 {
create_commit(
&repo,
&CommitRequest {
message: format!("empty-{i}"),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: true,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("empty");
}
assert!(count_loose_objects(repo.odb()).expect("loose") >= 2);
repack_all(
&repo,
&RepackOptions {
incremental: true,
write_bitmap: false,
..RepackOptions::default()
},
)
.expect("incremental repack");
assert!(
count_pack_files(&pack_dir) >= 2,
"incremental repack should add a pack, not replace the base"
);
}
#[test]
fn gc_full_run_records_per_phase_timings() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
let wt = repo.work_tree().unwrap();
for i in 0..5 {
std::fs::write(wt.join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage");
let ident = "Test <t@example.com> 1 +0000".to_string();
create_commit(
&repo,
&CommitRequest {
message: format!("c{i}"),
author: Some(ident.parse().expect("signature")),
committer: Some(ident.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
},
&mut NullProgress,
)
.expect("commit");
}
let stats = gc(
&repo,
&GcOptions::full_run(false, OffsetDateTime::now_utc().unix_timestamp()),
)
.expect("gc");
let t = stats.phase_timings;
assert!(stats.repack.objects_packed.is_some_and(|n| n > 0));
assert!(
t.repack_ms > 0,
"repack phase must be timed separately from full gc"
);
assert!(stats.pack_refs.is_some());
assert!(stats.commit_graph.is_some());
assert_eq!(
t.prune_ms, stats.repack.unreachable_prune_ms,
"prune_ms must track unreachable loose prune inside repack, not repack_ms"
);
assert!(
t.repack_ms > 0 && stats.pack_refs.is_some() && stats.commit_graph.is_some(),
"full gc must time repack and run pack-refs + commit-graph, got {t:?}"
);
assert!(stats.prune_ran, "full gc should run unreachable prune");
}
}