use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::SystemTime;
use std::{fs, io};
use super::RetentionPolicy;
use crate::{ObjectId, Repository};
pub trait MaintenanceIsolation {
type Guard;
fn acquire(&mut self, repository: &Repository) -> io::Result<Self::Guard>;
}
#[derive(Clone, Debug, Default)]
pub struct PruneReport {
pub deleted: Vec<ObjectId>,
}
#[derive(Debug, thiserror::Error)]
pub enum PruneCause {
#[error(transparent)]
PackChanged(Box<crate::ObjectReadError>),
#[error("maintenance isolation unavailable")]
Isolation(#[source] io::Error),
#[error("retention scan incomplete: {0}")]
Incomplete(String),
#[error("maintenance generation changed")]
Changed,
#[error("maintenance cancelled")]
Cancelled,
#[error("maintenance I/O")]
Io(#[source] io::Error),
}
#[derive(Debug, thiserror::Error)]
#[error("loose object pruning failed")]
pub struct PruneFailure {
#[source]
pub cause: PruneCause,
pub report: PruneReport,
pub uncertain: Option<ObjectId>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct Candidate {
id: ObjectId,
path: PathBuf,
len: u64,
modified: SystemTime,
}
impl Repository {
pub fn prune_unreachable_loose<I: MaintenanceIsolation>(
&self,
isolation: &mut I,
policy: &RetentionPolicy,
cancel: &AtomicBool,
) -> Result<PruneReport, PruneFailure> {
let _guard = isolation
.acquire(self)
.map_err(|error| failure(PruneCause::Isolation(error), PruneReport::default()))?;
self.prune_unreachable_loose_exclusive(policy, cancel)
}
pub(super) fn prune_unreachable_loose_exclusive(
&self,
policy: &RetentionPolicy,
cancel: &AtomicBool,
) -> Result<PruneReport, PruneFailure> {
let report = self.prune_unreachable_loose_at(
policy,
cancel,
|| {},
|| {},
|path| fs::remove_file(path),
)?;
self.finish_prune(report)
}
fn finish_prune(&self, report: PruneReport) -> Result<PruneReport, PruneFailure> {
if !report.deleted.is_empty() {
let info = self.object_dir().join("info");
let remove = |result: io::Result<()>| match result {
Err(error) if error.kind() != io::ErrorKind::NotFound => {
Err(failure(PruneCause::Io(error), report.clone()))
}
_ => Ok(()),
};
remove(fs::remove_file(info.join("commit-graph")))?;
remove(fs::remove_dir_all(info.join("commit-graphs")))?;
}
Ok(report)
}
fn prune_unreachable_loose_at(
&self,
policy: &RetentionPolicy,
cancel: &AtomicBool,
after_plan: impl FnOnce(),
after_delete: impl FnMut(),
remove: impl FnMut(&std::path::Path) -> io::Result<()>,
) -> Result<PruneReport, PruneFailure> {
self.prune_planned_at(
policy,
cancel,
None,
(|| {}, after_plan, after_delete),
remove,
)
}
#[cfg(unix)]
pub(super) fn prune_planned(
&self,
policy: &RetentionPolicy,
cancel: &AtomicBool,
session: &super::inputs::Session,
) -> Result<PruneReport, PruneFailure> {
let report = self.prune_planned_at(
policy,
cancel,
Some(session),
(|| {}, || {}, || {}),
|path| fs::remove_file(path),
)?;
self.finish_prune(report)
}
fn prune_planned_at(
&self,
policy: &RetentionPolicy,
cancel: &AtomicBool,
session: Option<&super::inputs::Session>,
checkpoints: (impl FnOnce(), impl FnOnce(), impl FnMut()),
mut remove: impl FnMut(&std::path::Path) -> io::Result<()>,
) -> Result<PruneReport, PruneFailure> {
let _headers = crate::pack::HeaderScope::new();
let (before_plan, after_plan, mut after_delete) = checkpoints;
let mut report = PruneReport::default();
check(cancel, &report)?;
let current = Repository::open(self.git_dir())
.map_err(|error| failure(PruneCause::Incomplete(error.to_string()), report.clone()))?;
if current.object_dir() != self.object_dir()
|| current.object_format() != self.object_format()
{
return Err(failure(PruneCause::Changed, report));
}
let observed = candidates(¤t, policy, cancel)
.map_err(|cause| failure(cause, PruneReport::default()))?;
before_plan();
if session.is_none() && observed.is_empty() {
check(cancel, &report)?;
return Ok(report);
}
let owned = if session.is_none() {
Some(
super::inputs::Session::new(¤t, policy, cancel).map_err(|reason| {
failure(
if cancel.load(Ordering::Relaxed) {
PruneCause::Cancelled
} else {
PruneCause::Incomplete(reason)
},
report.clone(),
)
})?,
)
} else {
None
};
let session = session.or(owned.as_ref()).expect("retention session");
let first = &session.plan;
after_plan();
check(cancel, &report)?;
let refreshed = Repository::open(self.git_dir())
.map_err(|error| failure(PruneCause::Incomplete(error.to_string()), report.clone()))?;
if refreshed.object_dir() != current.object_dir()
|| refreshed.object_format() != current.object_format()
|| !refreshed
.shallow_roots()
.iter()
.eq(current.shallow_roots().iter())
{
return Err(failure(PruneCause::Changed, report));
}
session
.plan
.check_packs_unchanged(cancel)
.map_err(|error| failure(PruneCause::PackChanged(Box::new(error)), report.clone()))?;
session.validate(&refreshed, policy, cancel).map_err(|_| {
failure(
if cancel.load(Ordering::Relaxed) {
PruneCause::Cancelled
} else {
PruneCause::Changed
},
report.clone(),
)
})?;
let repeated = candidates(&refreshed, policy, cancel)
.map_err(|cause| failure(cause, report.clone()))?;
if observed != repeated {
return Err(failure(PruneCause::Changed, report));
}
for candidate in observed {
check(cancel, &report)?;
if first.reachable.contains(&candidate.id)
|| first.protected_objects.contains(&candidate.id)
{
continue;
}
let metadata = fs::symlink_metadata(&candidate.path)
.map_err(|error| failure(PruneCause::Io(error), report.clone()))?;
let modified = metadata
.modified()
.map_err(|error| failure(PruneCause::Io(error), report.clone()))?;
if !metadata.is_file()
|| metadata.len() != candidate.len
|| modified != candidate.modified
{
return Err(failure(PruneCause::Changed, report));
}
session
.plan
.check_packs_unchanged(cancel)
.map_err(|error| {
failure(PruneCause::PackChanged(Box::new(error)), report.clone())
})?;
remove(&candidate.path).map_err(|error| PruneFailure {
cause: PruneCause::Io(error),
report: report.clone(),
uncertain: Some(candidate.id),
})?;
report.deleted.push(candidate.id);
after_delete();
}
Ok(report)
}
}
pub(super) fn candidates(
repository: &Repository,
policy: &RetentionPolicy,
cancel: &AtomicBool,
) -> Result<Vec<Candidate>, PruneCause> {
let mut result = Vec::new();
let mut visited = 0usize;
for entry in fs::read_dir(repository.object_dir()).map_err(PruneCause::Io)? {
check_count(&mut visited, policy.max_entries)?;
check_cancel(cancel)?;
let entry = entry.map_err(PruneCause::Io)?;
let name = entry.file_name();
if name == "pack" || name == "info" {
continue;
}
let Some(prefix) = name.to_str() else {
return Err(PruneCause::Incomplete("non-UTF-8 object fanout".into()));
};
if prefix.len() != 2 || !prefix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(PruneCause::Incomplete(
"unknown object directory entry".into(),
));
}
if !fs::symlink_metadata(entry.path())
.map_err(PruneCause::Io)?
.is_dir()
{
return Err(PruneCause::Incomplete("non-directory object fanout".into()));
}
for file in fs::read_dir(entry.path()).map_err(PruneCause::Io)? {
check_count(&mut visited, policy.max_entries)?;
check_cancel(cancel)?;
let file = file.map_err(PruneCause::Io)?;
let path = file.path();
let metadata = fs::symlink_metadata(&path).map_err(PruneCause::Io)?;
if !metadata.is_file() {
return Err(PruneCause::Incomplete("non-file loose object".into()));
}
let Some(suffix) = file.file_name().to_str().map(str::to_owned) else {
return Err(PruneCause::Incomplete("non-UTF-8 loose object".into()));
};
let id = ObjectId::from_hex(repository.object_format(), &format!("{prefix}{suffix}"))
.map_err(|error| PruneCause::Incomplete(error.to_string()))?;
let modified = metadata.modified().map_err(PruneCause::Io)?;
if modified < policy.recent_cutoff {
result.push(Candidate {
id,
path,
len: metadata.len(),
modified,
});
}
}
}
result.sort_by(|a, b| a.path.cmp(&b.path));
Ok(result)
}
fn check_count(visited: &mut usize, limit: usize) -> Result<(), PruneCause> {
*visited += 1;
if *visited > limit {
Err(PruneCause::Incomplete("loose object entry limit".into()))
} else {
Ok(())
}
}
fn check_cancel(cancel: &AtomicBool) -> Result<(), PruneCause> {
if cancel.load(Ordering::Relaxed) {
Err(PruneCause::Cancelled)
} else {
Ok(())
}
}
fn check(cancel: &AtomicBool, report: &PruneReport) -> Result<(), PruneFailure> {
check_cancel(cancel).map_err(|cause| {
failure(
cause,
PruneReport {
deleted: report.deleted.clone(),
},
)
})
}
fn failure(cause: PruneCause, report: PruneReport) -> PruneFailure {
PruneFailure {
cause,
report,
uncertain: None,
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::*;
fn git(root: &Path, args: &[&str], input: &[u8]) -> String {
let mut child = Command::new("git")
.current_dir(root)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", root.join("absent-config"))
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(input).unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn fixture() -> (tempfile::TempDir, Repository, RetentionPolicy) {
let root = tempfile::tempdir().unwrap();
git(root.path(), &["init", "--bare", "--template=", "."], b"");
let repo = Repository::open(root.path()).unwrap();
let policy = RetentionPolicy {
recent_cutoff: SystemTime::now() + Duration::from_secs(60),
..Default::default()
};
(root, repo, policy)
}
#[test]
fn new_root_between_scans_refuses_before_deletion() {
let (root, repo, policy) = fixture();
let id = git(root.path(), &["hash-object", "-w", "--stdin"], b"candidate");
let cancel = AtomicBool::new(false);
let result = repo.prune_unreachable_loose_at(
&policy,
&cancel,
|| {
git(root.path(), &["update-ref", "refs/tags/blob", &id], b"");
},
|| {},
|path| fs::remove_file(path),
);
let error = result.unwrap_err();
assert!(matches!(error.cause, PruneCause::Changed));
assert!(error.report.deleted.is_empty());
assert_eq!(git(root.path(), &["cat-file", "-t", &id], b""), "blob");
}
#[test]
fn new_loose_root_between_inventory_and_plan_refuses_before_deletion() {
let (root, repo, policy) = fixture();
let old = git(
root.path(),
&["hash-object", "-w", "--stdin"],
b"old candidate",
);
let cancel = AtomicBool::new(false);
let result = repo.prune_planned_at(
&policy,
&cancel,
None,
(
|| {
let added = git(root.path(), &["hash-object", "-w", "--stdin"], b"new root");
git(root.path(), &["update-ref", "refs/tags/added", &added], b"");
},
|| {},
|| {},
),
|path| fs::remove_file(path),
);
let error = result.unwrap_err();
assert!(matches!(error.cause, PruneCause::Changed));
assert!(error.report.deleted.is_empty());
assert_eq!(
git(root.path(), &["cat-file", "blob", "refs/tags/added"], b""),
"new root"
);
assert_eq!(
git(root.path(), &["cat-file", "blob", &old], b""),
"old candidate"
);
}
#[test]
fn cancellation_after_first_unlink_reports_partial_prefix() {
let (root, repo, policy) = fixture();
let first = git(root.path(), &["hash-object", "-w", "--stdin"], b"first");
let second = git(root.path(), &["hash-object", "-w", "--stdin"], b"second");
let cancel = AtomicBool::new(false);
let error = repo
.prune_unreachable_loose_at(
&policy,
&cancel,
|| {},
|| cancel.store(true, Ordering::Relaxed),
|path| fs::remove_file(path),
)
.unwrap_err();
assert!(matches!(error.cause, PruneCause::Cancelled));
assert_eq!(error.report.deleted.len(), 1);
let deleted = error.report.deleted[0].to_string();
assert!(deleted == first || deleted == second);
let survivor = if deleted == first { second } else { first };
assert_eq!(
git(root.path(), &["cat-file", "-t", &survivor], b""),
"blob"
);
}
#[test]
fn unlink_fault_reports_known_prefix_and_uncertain_target() {
let (root, repo, policy) = fixture();
let first = git(root.path(), &["hash-object", "-w", "--stdin"], b"first");
let second = git(root.path(), &["hash-object", "-w", "--stdin"], b"second");
let mut calls = 0;
let error = repo
.prune_unreachable_loose_at(
&policy,
&AtomicBool::new(false),
|| {},
|| {},
|path| {
calls += 1;
if calls == 2 {
Err(io::ErrorKind::PermissionDenied.into())
} else {
fs::remove_file(path)
}
},
)
.unwrap_err();
assert!(matches!(error.cause, PruneCause::Io(_)));
assert_eq!(error.report.deleted.len(), 1);
let deleted = error.report.deleted[0].to_string();
let uncertain = error.uncertain.unwrap().to_string();
assert!(deleted == first || deleted == second);
assert!(uncertain == first || uncertain == second);
assert_ne!(deleted, uncertain);
assert_eq!(
git(root.path(), &["cat-file", "-t", &uncertain], b""),
"blob"
);
}
#[cfg(unix)]
#[rstest::rstest]
#[case::truncate(true)]
#[case::overwrite(false)]
fn warmed_pack_change_refuses_prune(#[case] truncate: bool) {
let (_root, repo, policy, pack) = super::super::walk_tests::header_fixture();
let error = repo
.prune_unreachable_loose_at(
&policy,
&AtomicBool::new(false),
|| super::super::walk_tests::change_pack(&pack, truncate),
|| {},
|_| panic!("must not unlink"),
)
.unwrap_err();
assert!(matches!(&error.cause, PruneCause::PackChanged(source)
if matches!(source.as_ref(), crate::ObjectReadError::PackChanged { path } if path == &pack)));
assert!(error.report.deleted.is_empty());
}
}