mod config;
mod gc;
mod lock;
mod policy;
mod prefetch;
#[cfg_attr(
not(test),
expect(dead_code, reason = "prefetch execution follows separately")
)]
mod prefetch_config;
#[cfg(test)]
mod prefetch_config_tests;
mod run;
mod tasks;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{fs, io};
pub use config::{AutoThreshold, MaintenanceConfig, MaintenanceStrategy, TaskConfigError};
pub use gc::{GcCoordinationError, GcLock, GcLog};
pub use lock::{LockError, MaintenanceLock};
pub use policy::PolicyError;
pub use prefetch::{
PrefetchLimits, PrefetchRefspecs, PrefetchRefspecsError, PrefetchSkipReason,
SkippedPrefetchSpec,
};
pub use run::{
IncrementalRepackLimits, IncrementalRepackReport, LooseObjectsReport, MaintenanceFailure,
MaintenanceLimits, MaintenanceReport, MaintenanceRun, MaintenanceRunError, TaskOutcome,
TaskReport, TaskSkip,
};
pub use tasks::{MaintenanceTask, TaskSelection, TaskSelectionError};
use crate::{Config, Repository};
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct CheckLimits {
pub max_entries: usize,
}
impl Default for CheckLimits {
fn default() -> Self {
Self {
max_entries: 100_000,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum AutoGc {
Disabled,
NotDue,
Due {
reason: GcReason,
detach: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GcReason {
Packs,
LooseObjects,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CheckError {
#[error("invalid automatic gc configuration")]
Config(#[from] crate::config::InvalidValue),
#[error("cannot inspect automatic gc inputs")]
Io(#[from] io::Error),
#[error("automatic gc directory entry limit exceeded")]
EntryLimit,
#[error("automatic gc check cancelled")]
Cancelled,
}
impl Repository {
pub fn check_auto_gc(
&self,
limits: CheckLimits,
cancel: &AtomicBool,
) -> Result<AutoGc, CheckError> {
checkpoint(cancel)?;
let config = self.config();
let loose_limit = integer(config, "auto", 6700)?;
let pack_limit = integer(config, "autoPackLimit", 50)?;
let detach = config.boolean("gc", None, "autoDetach")?.unwrap_or(true);
if loose_limit <= 0 {
return Ok(AutoGc::Disabled);
}
let mut remaining = limits.max_entries;
if pack_limit > 0 {
let mut packs = 0;
let directory = self.object_dir().join("pack");
for entry in entries(&directory)? {
visit(&mut remaining, cancel)?;
let path = entry?.path();
if path.extension().is_some_and(|extension| extension == "idx")
&& path.with_extension("pack").try_exists()?
&& !path.with_extension("keep").try_exists()?
{
packs += 1;
if packs > pack_limit {
return Ok(AutoGc::Due {
reason: GcReason::Packs,
detach,
});
}
}
}
}
let threshold = (loose_limit as usize).div_ceil(256);
let mut loose = 0;
for entry in entries(&self.object_dir().join("17"))? {
visit(&mut remaining, cancel)?;
let name = entry?.file_name();
let name = name.to_string_lossy();
if name.len() == self.object_format().digest_len() * 2 - 2
&& name
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
loose += 1;
if loose > threshold {
return Ok(AutoGc::Due {
reason: GcReason::LooseObjects,
detach,
});
}
}
}
checkpoint(cancel)?;
Ok(AutoGc::NotDue)
}
}
fn integer(config: &Config, key: &str, default: i64) -> Result<i32, CheckError> {
let value = config.integer("gc", None, key)?.unwrap_or(default);
i32::try_from(value).map_err(|_| {
crate::config::InvalidValue::new("gc", key, config.value("gc", None, key).flatten()).into()
})
}
fn entries(path: &Path) -> io::Result<impl Iterator<Item = io::Result<fs::DirEntry>>> {
match fs::read_dir(path) {
Ok(entries) => Ok(Some(entries).into_iter().flatten()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None.into_iter().flatten()),
Err(error) => Err(error),
}
}
fn checkpoint(cancel: &AtomicBool) -> Result<(), CheckError> {
if cancel.load(Ordering::Relaxed) {
Err(CheckError::Cancelled)
} else {
Ok(())
}
}
fn visit(remaining: &mut usize, cancel: &AtomicBool) -> Result<(), CheckError> {
checkpoint(cancel)?;
*remaining = remaining.checked_sub(1).ok_or(CheckError::EntryLimit)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{InitKind, ObjectFormat};
fn fixture(format: ObjectFormat, config: &str) -> (tempfile::TempDir, Repository) {
let root = tempfile::tempdir().unwrap();
let repo = Repository::init(format, root.path().join("r"), InitKind::Bare).unwrap();
let path = repo.common_dir().join("config");
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(config.as_bytes());
fs::write(path, bytes).unwrap();
let repo = Repository::open(root.path().join("r")).unwrap();
(root, repo)
}
fn check(repo: &Repository) -> AutoGc {
repo.check_auto_gc(CheckLimits::default(), &AtomicBool::new(false))
.unwrap()
}
#[rstest::rstest]
#[case(ObjectFormat::Sha1)]
#[case(ObjectFormat::Sha256)]
fn loose_sample_strict_boundary(#[case] format: ObjectFormat) {
let (_root, repo) = fixture(format, "\n[gc]\nauto=256\nautoDetach=false\n");
let directory = repo.object_dir().join("17");
fs::create_dir(&directory).unwrap();
let length = format.digest_len() * 2 - 2;
fs::write(
directory.join(format!("{:0length$x}", 1)),
b"threshold fixture",
)
.unwrap();
assert_eq!(check(&repo), AutoGc::NotDue);
fs::write(directory.join("invalid-name"), b"").unwrap();
assert_eq!(check(&repo), AutoGc::NotDue);
fs::write(
directory.join(format!("{:0length$x}", 2)),
b"threshold fixture",
)
.unwrap();
assert_eq!(
check(&repo),
AutoGc::Due {
reason: GcReason::LooseObjects,
detach: false
}
);
}
#[rstest::rstest]
#[case(ObjectFormat::Sha1)]
#[case(ObjectFormat::Sha256)]
fn sample_selection_matches_git(#[case] format: ObjectFormat) {
let (root, repo) = fixture(format, "\n[gc]\nauto=256\nautoDetach=false\n");
let directory = repo.object_dir().join("17");
fs::create_dir(&directory).unwrap();
for count in 1..=2 {
let length = format.digest_len() * 2 - 2;
fs::write(
directory.join(format!("{count:0length$x}")),
b"invalid threshold fixture",
)
.unwrap();
let mut command = std::process::Command::new("git");
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("GIT_") {
command.env_remove(key);
}
}
let output = command
.current_dir(repo.git_dir())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", root.path().join("absent-config"))
.env("GIT_AUTHOR_NAME", "Maintenance Fixture")
.env("GIT_AUTHOR_EMAIL", "maintenance@example.com")
.env("GIT_COMMITTER_NAME", "Maintenance Fixture")
.env("GIT_COMMITTER_EMAIL", "maintenance@example.com")
.env("GIT_AUTHOR_DATE", "@1700000000 +0000")
.env("GIT_COMMITTER_DATE", "@1700000000 +0000")
.args(["-c", "commit.gpgsign=false", "gc", "--auto"])
.output()
.unwrap();
let selected = String::from_utf8_lossy(&output.stderr).contains("Auto packing");
assert_eq!(selected, count == 2, "{output:?}");
assert_eq!(matches!(check(&repo), AutoGc::Due { .. }), selected);
}
}
#[test]
fn packs_exclude_kept_and_unindexed() {
let (_root, repo) = fixture(ObjectFormat::Sha1, "\n[gc]\nautoPackLimit=1\n");
let directory = repo.object_dir().join("pack");
fs::create_dir_all(&directory).unwrap();
for (name, extensions) in [
("a", &["pack", "idx"][..]),
("b", &["pack", "idx", "keep"][..]),
("c", &["pack"][..]),
] {
for ext in extensions {
fs::write(directory.join(format!("pack-{name}.{ext}")), b"").unwrap();
}
}
assert_eq!(check(&repo), AutoGc::NotDue);
fs::remove_file(directory.join("pack-b.keep")).unwrap();
assert_eq!(
check(&repo),
AutoGc::Due {
reason: GcReason::Packs,
detach: true
}
);
}
#[rstest::rstest]
#[case("auto=0")]
#[case("auto=-1")]
fn disabled_thresholds(#[case] config: &str) {
let (_root, repo) = fixture(ObjectFormat::Sha1, &format!("\n[gc]\n{config}\n"));
assert_eq!(check(&repo), AutoGc::Disabled);
}
#[test]
fn checks_are_bounded_and_cancellable() {
let (_root, repo) = fixture(ObjectFormat::Sha1, "");
fs::create_dir_all(repo.object_dir().join("17")).unwrap();
fs::write(repo.object_dir().join("17/ignored"), b"").unwrap();
assert!(matches!(
repo.check_auto_gc(CheckLimits { max_entries: 0 }, &AtomicBool::new(false)),
Err(CheckError::EntryLimit)
));
assert!(matches!(
repo.check_auto_gc(CheckLimits::default(), &AtomicBool::new(true)),
Err(CheckError::Cancelled)
));
assert!(!repo.common_dir().join("gc.pid").exists());
assert!(!repo.common_dir().join("gc.log").exists());
}
#[rstest::rstest]
#[case("auto=junk")]
#[case("auto=2147483648")]
#[case("autoPackLimit=-2147483649")]
#[case("autoDetach=junk")]
#[case("auto=0\nautoDetach=junk")]
#[case("auto=0\nautoPackLimit=bad")]
fn invalid_config_refused(#[case] config: &str) {
let (_root, repo) = fixture(ObjectFormat::Sha1, &format!("\n[gc]\n{config}\n"));
assert!(matches!(
repo.check_auto_gc(CheckLimits::default(), &AtomicBool::new(false)),
Err(CheckError::Config(_))
));
}
}