#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum PackConsolidation {
#[default]
Configured,
Always,
Never,
}
#[cfg(unix)]
pub(super) use select::SelectError;
#[cfg(unix)]
mod select {
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{fs, io};
use super::super::RetentionError;
use super::PackConsolidation;
use crate::Repository;
use crate::config::InvalidValue;
const DEFAULT_PACK_LIMIT: i64 = 50;
#[derive(Debug)]
pub(in crate::retention) enum SelectError {
Config(InvalidValue),
Observe(RetentionError),
Cancelled,
}
impl PackConsolidation {
pub(in crate::retention) fn selected(
self,
repository: &Repository,
max_entries: usize,
cancel: &AtomicBool,
) -> Result<bool, SelectError> {
let limit = match self {
Self::Always => return Ok(true),
Self::Never => return Ok(false),
Self::Configured => configured_limit(repository).map_err(SelectError::Config)?,
};
let Some(limit) = limit else {
return Ok(false);
};
let count = counted_packs(repository, max_entries, cancel)?;
Ok(count > limit)
}
}
fn configured_limit(repository: &Repository) -> Result<Option<usize>, InvalidValue> {
let config = repository.config();
let limit = config
.integer("gc", None, "autoPackLimit")?
.unwrap_or(DEFAULT_PACK_LIMIT);
if i32::try_from(limit).is_err() {
return Err(InvalidValue::new(
"gc",
"autoPackLimit",
config.value("gc", None, "autoPackLimit").flatten(),
));
}
Ok(usize::try_from(limit).ok().filter(|&limit| limit > 0))
}
fn counted_packs(
repository: &Repository,
max_entries: usize,
cancel: &AtomicBool,
) -> Result<usize, SelectError> {
let directory = repository.object_dir().join("pack");
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(SelectError::Observe(RetentionError::read(error))),
};
let mut names = std::collections::BTreeSet::<PathBuf>::new();
for entry in entries {
if cancel.load(Ordering::Relaxed) {
return Err(SelectError::Cancelled);
}
if names.len() >= max_entries {
return Err(SelectError::Observe(
RetentionError::PackDirectoryEntryLimit,
));
}
let entry = entry.map_err(|error| SelectError::Observe(RetentionError::read(error)))?;
names.insert(entry.path());
}
Ok(names
.iter()
.filter(|path| {
path.extension().is_some_and(|ext| ext == "idx")
&& names.contains(&path.with_extension("pack"))
&& !names.contains(&path.with_extension("keep"))
})
.count())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(config: &str, packs: &[&[&str]]) -> (tempfile::TempDir, Repository) {
let root = tempfile::tempdir().unwrap();
let repository = Repository::init(
crate::ObjectFormat::Sha1,
root.path().join("r"),
crate::InitKind::Bare,
)
.unwrap();
let path = repository.git_dir().join("config");
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(config.as_bytes());
fs::write(&path, bytes).unwrap();
let directory = repository.object_dir().join("pack");
fs::create_dir_all(&directory).unwrap();
for (number, extensions) in packs.iter().enumerate() {
for extension in *extensions {
fs::write(
directory.join(format!("pack-{number:040x}.{extension}")),
b"",
)
.unwrap();
}
}
let repository = Repository::open(repository.git_dir()).unwrap();
(root, repository)
}
const PAIR: &[&str] = &["idx", "pack"];
#[rstest::rstest]
#[case::above_limit("[gc]\n\tautoPackLimit = 3\n", &[PAIR, PAIR, PAIR, PAIR], true)]
#[case::at_limit("[gc]\n\tautoPackLimit = 4\n", &[PAIR, PAIR, PAIR, PAIR], false)]
#[case::keep_uncounted(
"[gc]\n\tautoPackLimit = 3\n",
&[PAIR, PAIR, PAIR, &["idx", "pack", "keep"]],
false
)]
#[case::promisor_counted(
"[gc]\n\tautoPackLimit = 3\n",
&[PAIR, PAIR, PAIR, &["idx", "pack", "promisor"]],
true
)]
#[case::unindexed_uncounted("[gc]\n\tautoPackLimit = 3\n", &[PAIR, PAIR, PAIR, &["pack"]], false)]
#[case::zero_disables("[gc]\n\tautoPackLimit = 0\n", &[PAIR, PAIR], false)]
#[case::negative_disables("[gc]\n\tautoPackLimit = -1\n", &[PAIR, PAIR], false)]
#[case::unit_suffix("[gc]\n\tautoPackLimit = 1k\n", &[PAIR, PAIR], false)]
#[case::default_limit("", &[PAIR, PAIR], false)]
fn configured_selection_matches_git(
#[case] config: &str,
#[case] packs: &[&[&str]],
#[case] expected: bool,
) {
let (_root, repository) = fixture(config, packs);
let selected = PackConsolidation::Configured
.selected(&repository, 1000, &AtomicBool::new(false))
.unwrap();
assert_eq!(selected, expected);
}
#[rstest::rstest]
#[case::above(51, true)]
#[case::at(50, false)]
fn default_limit_is_fifty(#[case] count: usize, #[case] expected: bool) {
let (_root, repository) = fixture("", &vec![PAIR; count]);
let selected = PackConsolidation::Configured
.selected(&repository, 1000, &AtomicBool::new(false))
.unwrap();
assert_eq!(selected, expected);
}
#[rstest::rstest]
#[case::word("[gc]\n\tautoPackLimit = abc\n")]
#[case::empty("[gc]\n\tautoPackLimit =\n")]
#[case::implicit("[gc]\n\tautoPackLimit\n")]
#[case::bad_unit("[gc]\n\tautoPackLimit = 3x\n")]
#[case::above_32_bits("[gc]\n\tautoPackLimit = 2147483648\n")]
#[case::below_32_bits("[gc]\n\tautoPackLimit = -2147483649\n")]
fn malformed_limit_is_refused(#[case] config: &str) {
let (_root, repository) = fixture(config, &[PAIR]);
assert!(matches!(
PackConsolidation::Configured.selected(&repository, 1000, &AtomicBool::new(false)),
Err(SelectError::Config(_))
));
}
#[rstest::rstest]
#[case::always(PackConsolidation::Always, true)]
#[case::never(PackConsolidation::Never, false)]
fn explicit_choices_ignore_configuration(
#[case] choice: PackConsolidation,
#[case] expected: bool,
) {
let (_root, repository) = fixture("[gc]\n\tautoPackLimit = abc\n", &[PAIR]);
assert_eq!(
choice
.selected(&repository, 1000, &AtomicBool::new(false))
.unwrap(),
expected
);
}
}
}