use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use super::store::{Lock, io_error, parse_loose, read_optional};
use super::{Backend, LockWait, RefName, ReferenceError, References, Target, packed};
use crate::{ObjectId, Objects, PeelError, PeelFailure, PeelLimits};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PackScope {
Tags,
All,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PackTrigger {
Always,
Auto,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PackRefsOptions {
pub scope: PackScope,
pub trigger: PackTrigger,
pub lock_wait: LockWait,
}
impl PackRefsOptions {
pub const ALL: Self = Self {
scope: PackScope::All,
trigger: PackTrigger::Always,
lock_wait: LockWait::Immediate,
};
pub const AUTO: Self = Self {
scope: PackScope::All,
trigger: PackTrigger::Auto,
lock_wait: LockWait::Immediate,
};
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PackRefsReport {
pub rewritten: bool,
pub packed: usize,
pub pruned: usize,
pub retained: Vec<RefName>,
pub skipped: Vec<RefName>,
}
#[derive(Debug, thiserror::Error)]
pub enum PackRefsError {
#[error("packing references")]
Reference(#[from] ReferenceError),
#[error("peeling packed reference {name:?}")]
Peel {
name: RefName,
#[source]
source: Box<PeelError>,
},
#[error("confirming packed-refs durability")]
Unconfirmed {
report: Box<PackRefsReport>,
#[source]
source: ReferenceError,
},
#[error("pruning packed loose references")]
Prune {
report: Box<PackRefsReport>,
#[source]
source: ReferenceError,
},
}
const HEADER: &[u8] = b"# pack-refs with: peeled fully-peeled sorted \n";
struct Candidate {
path: PathBuf,
id: ObjectId,
}
impl References<'_> {
pub fn pack(
&self,
objects: &Objects,
options: PackRefsOptions,
) -> Result<PackRefsReport, PackRefsError> {
if self.reference_backend != Backend::Files {
return Err(ReferenceError::Unsupported("packing reftable references").into());
}
let cancel = AtomicBool::new(false);
let lock = Lock::acquire_wait(
self.common_dir.join("packed-refs"),
options.lock_wait,
&cancel,
)?;
let _lock_operation = crate::locks::operation();
lock.check_owned()?;
let bytes = read_optional(&lock.destination)?.unwrap_or_default();
let existing = packed::parse(self.object_format, &bytes, &lock.destination)?;
let mut report = PackRefsReport::default();
let mut loose = BTreeMap::new();
self.collect_candidates(options.scope, &mut loose, &mut report)?;
if options.trigger == PackTrigger::Auto && loose.len() < auto_threshold(bytes.len()) {
return Ok(report);
}
let mut records = BTreeMap::new();
for (name, record) in &existing {
let peeled = match peel(objects, name, record.target)? {
Peel::Known(peeled) => peeled,
Peel::Missing | Peel::TagTargetMissing => record.peeled_hint,
};
records.insert(name.clone(), (record.target, peeled));
}
let mut packed_loose = BTreeMap::new();
for (name, candidate) in loose {
let peeled = match peel(objects, &name, candidate.id)? {
Peel::Known(peeled) => peeled,
Peel::TagTargetMissing => None,
Peel::Missing => {
report.skipped.push(name);
continue;
}
};
records.insert(name.clone(), (candidate.id, peeled));
packed_loose.insert(name, candidate);
}
lock.publish_synced(&encode(&records))?;
report.rewritten = true;
report.packed = packed_loose.len();
if !cfg!(unix) {
report.retained.extend(packed_loose.into_keys());
return Ok(report);
}
if let Err(source) =
checkpoint(Checkpoint::Published).and_then(|()| confirm_rename(self.common_dir))
{
return Err(PackRefsError::Unconfirmed {
report: Box::new(report),
source,
});
}
for (name, candidate) in packed_loose {
match prune(self.object_format, &candidate) {
Ok(true) => report.pruned += 1,
Ok(false) => report.retained.push(name),
Err(source) => {
return Err(PackRefsError::Prune {
report: Box::new(report),
source,
});
}
}
}
Ok(report)
}
fn collect_candidates(
&self,
scope: PackScope,
candidates: &mut BTreeMap<RefName, Candidate>,
report: &mut PackRefsReport,
) -> Result<(), ReferenceError> {
let mut pending = vec![(self.common_dir.join("refs"), b"refs".to_vec())];
while let Some((directory, prefix)) = pending.pop() {
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => return Err(io_error(&directory, source)),
};
for entry in entries {
let entry = entry.map_err(|source| io_error(&directory, source))?;
let file_type = entry
.file_type()
.map_err(|source| io_error(&entry.path(), source))?;
let Some(component) = component_bytes(&entry.file_name()) else {
continue;
};
if component.starts_with(b".") || component.ends_with(b".lock") {
continue;
}
let mut bytes = prefix.clone();
bytes.push(b'/');
bytes.extend_from_slice(&component);
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
pending.push((entry.path(), bytes));
continue;
}
let Ok(name) = RefName::new(&bytes) else {
continue;
};
if name.per_worktree() {
continue;
}
let selected = scope == PackScope::All || bytes.starts_with(b"refs/tags/");
if !file_type.is_file() || !selected {
continue;
}
let path = entry.path();
let Some(contents) = read_optional(&path)? else {
continue;
};
match parse_loose(self.object_format, &contents, &path) {
Ok(Target::Direct(id)) => {
candidates.insert(name, Candidate { path, id });
}
Ok(Target::Symbolic(_)) => (),
Err(_) => report.skipped.push(name),
}
}
}
Ok(())
}
}
#[cfg(unix)]
fn component_bytes(name: &std::ffi::OsStr) -> Option<Vec<u8>> {
use std::os::unix::ffi::OsStrExt;
Some(name.as_bytes().to_vec())
}
#[cfg(not(unix))]
fn component_bytes(name: &std::ffi::OsStr) -> Option<Vec<u8>> {
name.to_str().map(|name| name.as_bytes().to_vec())
}
#[cfg(unix)]
fn confirm_rename(directory: &std::path::Path) -> Result<(), ReferenceError> {
super::store::sync_directory(directory)
}
#[cfg(not(unix))]
fn confirm_rename(_: &std::path::Path) -> Result<(), ReferenceError> {
Err(ReferenceError::Unsupported(
"durable packed-refs replacement on this platform",
))
}
enum Peel {
Known(Option<ObjectId>),
Missing,
TagTargetMissing,
}
fn peel(objects: &Objects, name: &RefName, id: ObjectId) -> Result<Peel, PackRefsError> {
match objects.peel(id, PeelLimits::trusted(), &AtomicBool::new(false)) {
Ok(peeled) if peeled.tags.is_empty() => Ok(Peel::Known(None)),
Ok(peeled) => Ok(Peel::Known(Some(peeled.target))),
Err(error) if matches!(*error.source, PeelFailure::Missing) && error.depth == 0 => {
Ok(Peel::Missing)
}
Err(error) if matches!(*error.source, PeelFailure::Missing) => Ok(Peel::TagTargetMissing),
Err(source) => Err(PackRefsError::Peel {
name: name.clone(),
source: Box::new(source),
}),
}
}
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Checkpoint {
Published,
}
#[cfg(not(test))]
fn checkpoint(_: Checkpoint) -> Result<(), ReferenceError> {
Ok(())
}
#[cfg(test)]
type Hook = Box<dyn FnMut(Checkpoint) -> Result<(), ReferenceError>>;
#[cfg(test)]
thread_local! {
static HOOK: std::cell::RefCell<Option<Hook>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
fn checkpoint(at: Checkpoint) -> Result<(), ReferenceError> {
HOOK.with(|hook| hook.borrow_mut().as_mut().map_or(Ok(()), |hook| hook(at)))
}
fn encode(records: &BTreeMap<RefName, (ObjectId, Option<ObjectId>)>) -> Vec<u8> {
let mut bytes = HEADER.to_vec();
for (name, (id, peeled)) in records {
bytes.extend_from_slice(id.to_string().as_bytes());
bytes.push(b' ');
bytes.extend_from_slice(name.as_bytes());
bytes.push(b'\n');
if let Some(peeled) = peeled {
bytes.push(b'^');
bytes.extend_from_slice(peeled.to_string().as_bytes());
bytes.push(b'\n');
}
}
bytes
}
fn prune(format: crate::ObjectFormat, candidate: &Candidate) -> Result<bool, ReferenceError> {
let lock = match Lock::acquire(candidate.path.clone()) {
Ok(lock) => lock,
Err(ReferenceError::Locked(_)) => return Ok(false),
Err(error) => return Err(error),
};
let Some(bytes) = read_optional(&candidate.path)? else {
return Ok(true);
};
if !matches!(parse_loose(format, &bytes, &candidate.path), Ok(Target::Direct(id)) if id == candidate.id)
{
return Ok(false);
}
lock.check_owned()?;
match fs::remove_file(&candidate.path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(source) => Err(io_error(&candidate.path, source)),
}
}
fn auto_threshold(packed_bytes: usize) -> usize {
const BASE: f64 = 12_288.0;
if packed_bytes == 0 {
return 16;
}
let doublings = (packed_bytes as f64 / BASE).log2().floor();
((35.0 + 5.0 * doublings).max(16.0)) as usize
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::path::Path;
#[cfg(unix)]
use std::process::{Command, Output};
use rstest::rstest;
use super::*;
#[cfg(unix)]
use crate::{PackLimits, Repository};
#[cfg(unix)]
fn git_output(repo: &Path, args: &[&str]) -> Output {
Command::new("git")
.current_dir(repo)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "A")
.env("GIT_AUTHOR_EMAIL", "a@example.com")
.env("GIT_COMMITTER_NAME", "C")
.env("GIT_COMMITTER_EMAIL", "c@example.com")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.args(args)
.output()
.expect("Git is required for interoperability tests")
}
#[cfg(unix)]
fn git(repo: &Path, args: &[&str]) -> String {
let output = git_output(repo, args);
assert!(output.status.success(), "git {args:?}: {output:?}");
String::from_utf8(output.stdout).unwrap()
}
#[cfg(unix)]
fn fixture() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
let root = tempfile::tempdir().unwrap();
git(root.path(), &["init", "-q", "repo"]);
let repo = root.path().join("repo");
git(&repo, &["commit", "-q", "--allow-empty", "-m", "one"]);
git(&repo, &["branch", "topic"]);
git(&repo, &["tag", "-a", "-m", "t", "v1"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
let first = git(&repo, &["rev-parse", "HEAD~1"]).trim().to_owned();
let second = git(&repo, &["rev-parse", "HEAD"]).trim().to_owned();
(root, repo, first, second)
}
#[cfg(unix)]
fn view(repo: &Path) -> String {
git(
repo,
&[
"for-each-ref",
"--format=%(objectname) %(refname) %(symref)",
],
)
}
#[cfg(unix)]
fn pack_with_hook(
repo: &Path,
hook: impl FnMut(Checkpoint) -> Result<(), ReferenceError> + 'static,
) -> Result<PackRefsReport, PackRefsError> {
HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
let repository = Repository::open(repo).unwrap();
let result = repository.references().unwrap().pack(
&repository.objects(PackLimits::default()).unwrap(),
PackRefsOptions::ALL,
);
HOOK.with(|slot| *slot.borrow_mut() = None);
result
}
#[cfg(unix)]
#[test]
fn git_update_between_publish_and_prune_stays_loose_and_shadows() {
let (_root, repo, first, second) = fixture();
let hook_repo = repo.clone();
let report = pack_with_hook(&repo, move |_| {
git(&hook_repo, &["update-ref", "refs/heads/topic", &second]);
Ok(())
})
.unwrap();
assert_eq!(report.retained, [RefName::new("refs/heads/topic").unwrap()]);
let packed = fs::read_to_string(repo.join(".git/packed-refs")).unwrap();
assert!(packed.contains(&format!("{first} refs/heads/topic\n")));
assert_eq!(
git(&repo, &["rev-parse", "topic"]).trim(),
git(&repo, &["rev-parse", "HEAD"]).trim()
);
}
#[cfg(unix)]
#[test]
fn ref_becoming_symbolic_between_publish_and_prune_stays_loose() {
let (_root, repo, _, _) = fixture();
let hook_repo = repo.clone();
let report = pack_with_hook(&repo, move |_| {
git(
&hook_repo,
&["symbolic-ref", "refs/heads/topic", "refs/heads/master"],
);
Ok(())
})
.unwrap();
assert_eq!(report.retained, [RefName::new("refs/heads/topic").unwrap()]);
assert_eq!(
git(&repo, &["symbolic-ref", "refs/heads/topic"]).trim(),
"refs/heads/master"
);
}
#[cfg(unix)]
#[test]
fn git_deletion_is_refused_while_packing() {
let (_root, repo, _, _) = fixture();
let hook_repo = repo.clone();
pack_with_hook(&repo, move |_| {
let output = git_output(
&hook_repo,
&[
"-c",
"core.packedRefsTimeout=0",
"update-ref",
"-d",
"refs/tags/v1",
],
);
assert!(!output.status.success());
Ok(())
})
.unwrap();
git(&repo, &["show-ref", "--verify", "refs/tags/v1"]);
}
#[cfg(unix)]
#[test]
fn failure_after_rename_keeps_every_loose_copy_and_reports_replacement() {
let (_root, repo, _, _) = fixture();
let before = view(&repo);
let loose = ["refs/heads/master", "refs/heads/topic", "refs/tags/v1"];
let hook_repo = repo.clone();
let hook_before = before.clone();
let result = pack_with_hook(&repo, move |_| {
assert_eq!(view(&hook_repo), hook_before);
Err(ReferenceError::Unsupported(
"injected directory sync failure",
))
});
let Err(PackRefsError::Unconfirmed { report, .. }) = result else {
panic!("expected an unconfirmed replacement: {result:?}");
};
assert!(report.rewritten);
assert_eq!(report.pruned, 0);
for name in loose {
assert!(repo.join(".git").join(name).exists(), "{name}");
}
assert_eq!(view(&repo), before);
assert!(!repo.join(".git/packed-refs.lock").exists());
}
#[rstest]
#[case(0, 16)]
#[case(1_066, 16)]
#[case(2_596, 20)]
#[case(5_246, 25)]
#[case(10_446, 30)]
#[case(15_646, 35)]
#[case(26_046, 40)]
#[case(53_046, 45)]
#[case(106_046, 50)]
#[case(212_046, 55)]
#[case(424_046, 60)]
#[case(864_046, 65)]
fn auto_threshold_matches_observed_git(#[case] size: usize, #[case] threshold: usize) {
assert_eq!(auto_threshold(size), threshold);
}
}