use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::AtomicBool;
use std::time::{Duration, SystemTime};
use crate::pack::counters;
use crate::retention::RetentionPolicy;
use crate::{PackLimits, Repository};
pub(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"))
.env("GIT_AUTHOR_NAME", "A")
.env("GIT_AUTHOR_EMAIL", "a@example.com")
.env("GIT_COMMITTER_NAME", "A")
.env("GIT_COMMITTER_EMAIL", "a@example.com")
.env("GIT_AUTHOR_DATE", "@1700000000 +0000")
.env("GIT_COMMITTER_DATE", "@1700000000 +0000")
.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 deltified_history(root: &Path) -> usize {
git(root, &["init", "-q", "--initial-branch=main", "."], b"");
for round in 0..24 {
for file in 0..60 {
if round > 0 && file % 12 != round % 12 {
continue;
}
let directory = root.join(format!("dir{}", file % 3));
std::fs::create_dir_all(&directory).unwrap();
let content: String = (0..40)
.map(|line| format!("file {file} line {line} {}\n", (line == 0) as usize * round))
.collect();
std::fs::write(directory.join(format!("f{file}")), content).unwrap();
}
git(root, &["add", "-A"], b"");
git(
root,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"-m",
&format!("r{round}"),
],
b"",
);
}
git(
root,
&[
"-c",
"pack.threads=1",
"repack",
"-a",
"-d",
"-f",
"-q",
"--window=50",
"--depth=50",
],
b"",
);
git(root, &["prune-packed"], b"");
std::fs::remove_dir_all(root.join(".git/logs")).unwrap();
let count = git(root, &["count-objects", "-v"], b"");
let line = count
.lines()
.find(|line| line.starts_with("in-pack: "))
.unwrap();
line["in-pack: ".len()..].parse().unwrap()
}
#[test]
fn retention_walk_reads_each_entry_once() {
let dir = tempfile::tempdir().unwrap();
let entries = deltified_history(dir.path());
let listing = git(
dir.path(),
&["verify-pack", "-v", &{
let pack = std::fs::read_dir(dir.path().join(".git/objects/pack"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| path.extension().is_some_and(|ext| ext == "idx"))
.unwrap();
pack.to_string_lossy().into_owned()
}],
b"",
);
let deltas = |kind: &str| {
listing
.lines()
.map(|line| line.split_whitespace().collect::<Vec<_>>())
.filter(|fields| fields.len() == 7 && fields[1] == kind)
.count()
};
assert!(
deltas("tree") > 24 && deltas("blob") > 24,
"fixture must deltify trees and blobs:\n{listing}"
);
let repository = Repository::open(dir.path()).unwrap();
let policy = RetentionPolicy {
recent_cutoff: Some(SystemTime::now() + Duration::from_secs(60)),
packs: PackLimits::trusted(),
..RetentionPolicy::trusted()
};
counters::reset();
let plan = repository.plan_retention(&policy, &AtomicBool::new(false));
assert!(plan.is_complete(), "{:?}", plan.outcome);
assert_eq!(plan.reachable.len(), entries);
let expected: std::collections::BTreeSet<crate::ObjectId> =
git(dir.path(), &["rev-list", "--objects", "--all"], b"")
.lines()
.map(|line| line.split_whitespace().next().unwrap().parse().unwrap())
.collect();
assert_eq!(plan.reachable, expected);
let repeated: Vec<_> = counters::INFLATIONS.with(|map| {
map.borrow()
.iter()
.filter(|(_, count)| **count > 1)
.map(|(offset, count)| (*offset, *count))
.collect()
});
assert!(
repeated.is_empty(),
"entries inflated more than once: {repeated:?}"
);
let reads = counters::OS_READS.with(|reads| reads.get());
assert!(
reads < entries / 4,
"{reads} system reads for {entries} entries"
);
}
#[cfg(unix)]
pub(super) fn header_fixture() -> (
tempfile::TempDir,
Repository,
RetentionPolicy,
std::path::PathBuf,
) {
let root = tempfile::tempdir().unwrap();
git(root.path(), &["init", "-q", "--template=", "."], b"");
let id = git(
root.path(),
&["hash-object", "-w", "--stdin"],
b"packed survivor",
);
git(root.path(), &["update-ref", "refs/tags/blob", &id], b"");
git(root.path(), &["repack", "-adq"], b"");
let orphan = git(
root.path(),
&["hash-object", "-w", "--stdin"],
b"loose orphan",
);
let orphan_path = root
.path()
.join(".git/objects")
.join(&orphan[..2])
.join(&orphan[2..]);
std::fs::File::open(orphan_path)
.unwrap()
.set_modified(std::time::UNIX_EPOCH)
.unwrap();
git(root.path(), &["fsck", "--full"], b"");
let repo = Repository::open(root.path()).unwrap();
let pack = std::fs::read_dir(repo.object_dir().join("pack"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| path.extension().is_some_and(|ext| ext == "pack"))
.unwrap();
let policy = RetentionPolicy {
recent_cutoff: Some(std::time::UNIX_EPOCH + Duration::from_secs(1)),
..RetentionPolicy::trusted()
};
(root, repo, policy, pack)
}
#[cfg(unix)]
pub(super) fn change_pack(path: &Path, truncate: bool) {
use std::io::{Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
if truncate {
file.set_len(12).unwrap();
} else {
let original = std::fs::read(path).unwrap()[12];
file.seek(SeekFrom::Start(12)).unwrap();
file.write_all(&[original ^ 1]).unwrap();
}
file.set_modified(std::time::UNIX_EPOCH + Duration::from_secs(42))
.unwrap();
}
#[cfg(unix)]
use crate::pack_git;
#[cfg(unix)]
#[rstest::rstest]
#[case::sha1(crate::ObjectFormat::Sha1)]
#[case::sha256(crate::ObjectFormat::Sha256)]
fn damage_rescued_after_complete_plan_refuses_prune(#[case] format: crate::ObjectFormat) {
let fixture = pack_git::Fixture::new(format, true, 4);
let expected = fixture
.records
.iter()
.find(|record| record.0 == fixture.ordinary)
.unwrap();
let hidden = fixture.root.path().join("hidden.idx");
std::fs::rename(&fixture.index_path, &hidden).unwrap();
pack_git::git(
fixture.root.path(),
&["hash-object", "-w", "--stdin"],
&expected.2,
);
std::fs::rename(hidden, &fixture.index_path).unwrap();
let orphan = pack_git::git(
fixture.root.path(),
&["hash-object", "-w", "--stdin"],
b"deletable orphan",
);
let mut index = std::fs::read(&fixture.index_path).unwrap();
let width = format.digest_len();
let count = fixture.records.len();
let position = index[1032..1032 + count * width]
.chunks_exact(width)
.position(|name| name == fixture.ordinary.as_bytes())
.unwrap();
index[1032 + count * width + position * 4] ^= 1;
let end = index.len() - width;
let checksum = format.checksum(&index[..end]);
index[end..].copy_from_slice(checksum.as_bytes());
std::fs::remove_file(&fixture.index_path).unwrap();
std::fs::write(&fixture.index_path, index).unwrap();
let policy = RetentionPolicy {
recent_cutoff: Some(SystemTime::now() + Duration::from_secs(60)),
..Default::default()
};
let cancel = AtomicBool::new(false);
let session = super::inputs::Session::new(&fixture.repo, &policy, &cancel).unwrap();
assert!(session.plan.is_complete());
let store = session.plan.store.as_ref().unwrap();
assert_eq!(
store
.read_with(fixture.ordinary, crate::ReadLimits::default())
.unwrap()
.unwrap()
.data(),
expected.2
);
let error = fixture
.repo
.prune_planned(&policy, &cancel, &session)
.unwrap_err();
assert!(
matches!(error.cause, super::PruneCause::PackChanged(ref source)
if matches!(source.as_ref(), crate::ObjectReadError::Path { source, .. } if source.kind() == std::io::ErrorKind::InvalidData))
);
assert!(error.report.deleted.is_empty());
assert_eq!(
pack_git::git(
fixture.root.path(),
&[
"cat-file",
"blob",
std::str::from_utf8(&orphan).unwrap().trim()
],
b""
),
b"deletable orphan"
);
assert!(
store
.read_with(fixture.delta, crate::ReadLimits::default())
.is_err()
);
}
fn kind_fixture(
root: &Path,
entries: &[(crate::EntryMode, &str, Option<crate::ObjectId>)],
) -> (Repository, crate::ObjectId) {
use crate::{Commit, CommitFields, Signature, Tree, TreeEntry};
git(root, &["init", "-q"], b"");
let repository = Repository::open(root).unwrap();
let loose = repository.loose_objects();
let format = repository.object_format();
let person = Signature {
name: b"A".to_vec(),
email: b"a@example.com".to_vec(),
seconds: 1_700_000_000,
offset_minutes: 0,
};
let commit_for = |tree| {
Commit::new(CommitFields {
tree,
parents: vec![],
author: person.clone(),
committer: person.clone(),
extra_headers: vec![],
message: b"kinds\n".to_vec(),
})
.unwrap()
};
let parent = commit_for(
loose
.write_tree(&Tree::new(format, vec![]).unwrap())
.unwrap(),
);
let parent_id = loose.write_commit(&parent).unwrap();
let tree = Tree::new(
format,
entries
.iter()
.map(|&(mode, name, id)| TreeEntry {
mode,
name: name.as_bytes().to_vec(),
id: id.unwrap_or(parent_id),
})
.collect(),
)
.unwrap();
let mut tip = commit_for(loose.write_tree(&tree).unwrap());
if entries.iter().any(|entry| entry.2.is_none()) {
tip = Commit::new(CommitFields {
tree: tree.id(),
parents: vec![parent_id],
author: person.clone(),
committer: person.clone(),
extra_headers: vec![],
message: b"kinds\n".to_vec(),
})
.unwrap();
}
let tip = loose.write_commit(&tip).unwrap();
git(
root,
&["update-ref", "refs/heads/main", &tip.to_string()],
b"",
);
(repository, parent_id)
}
fn plan_outcome(repository: &Repository) -> crate::retention::RetentionPlan {
let policy = RetentionPolicy {
recent_cutoff: Some(SystemTime::now() + Duration::from_secs(60)),
..RetentionPolicy::trusted()
};
repository.plan_retention(&policy, &AtomicBool::new(false))
}
#[test]
fn retention_walk_checks_kinds_of_repeated_edges() {
use crate::EntryMode::{Blob, Executable, Tree};
use crate::retention::{RetentionError, RetentionOutcome};
let blob = |root: &Path| -> crate::ObjectId {
git(root, &["init", "-q"], b"");
git(root, &["hash-object", "-w", "--stdin"], b"content\n")
.parse()
.unwrap()
};
let dir = tempfile::tempdir().unwrap();
let id = blob(dir.path());
let (repository, _) = kind_fixture(
dir.path(),
&[
(Blob, "a", Some(id)),
(Executable, "b", Some(id)),
(Blob, "c", Some(id)),
],
);
let plan = plan_outcome(&repository);
assert!(plan.is_complete(), "{:?}", plan.outcome);
assert!(plan.reachable.contains(&id));
assert_eq!(plan.reachable.len(), 3, "one commit, one tree and one blob");
let dir = tempfile::tempdir().unwrap();
let id = blob(dir.path());
let (repository, _) = kind_fixture(dir.path(), &[(Blob, "a", Some(id)), (Tree, "b", Some(id))]);
assert!(matches!(
plan_outcome(&repository).outcome,
RetentionOutcome::Incomplete(RetentionError::ConflictingObjectKind(conflict)) if conflict == id
));
let dir = tempfile::tempdir().unwrap();
let id = blob(dir.path());
let (repository, _) = kind_fixture(dir.path(), &[(Tree, "a", Some(id))]);
assert!(matches!(
plan_outcome(&repository).outcome,
RetentionOutcome::Incomplete(RetentionError::WrongObjectKind(wrong)) if wrong == id
));
let dir = tempfile::tempdir().unwrap();
let (repository, parent) = kind_fixture(dir.path(), &[(Blob, "a", None)]);
git(
dir.path(),
&["update-ref", "refs/heads/parent", &parent.to_string()],
b"",
);
assert!(matches!(
plan_outcome(&repository).outcome,
RetentionOutcome::Incomplete(RetentionError::WrongObjectKind(wrong)) if wrong == parent
));
}