use std::sync::atomic::AtomicBool;
use girt::ObjectFormat;
use girt::maintenance::{
PrefetchLimits, PrefetchRefspecs, PrefetchRefspecsError, PrefetchSkipReason,
};
use girt::refs::RefName;
use girt::remote::RefSource;
use rstest::rstest;
#[path = "support/maintenance_git.rs"]
mod oracle;
#[test]
fn rewrite_preserves_bytes_force_and_negative_selectors() {
let prepared = PrefetchRefspecs::parse(
[
b"+refs/heads/\xff:refs/cache/\xff".as_slice(),
b"^refs/heads/skip",
b"refs/heads/main",
b"+refs/tags/*:refs/custom/*",
],
PrefetchLimits::default(),
&AtomicBool::new(false),
)
.unwrap();
let specs: Vec<_> = prepared
.refspecs()
.specs()
.iter()
.map(|spec| spec.as_bytes())
.collect();
assert_eq!(
specs,
[
b"+refs/heads/\xff:refs/prefetch/cache/\xff".as_slice(),
b"^refs/heads/skip"
]
);
assert_eq!(prepared.skipped()[0].index, 2);
assert_eq!(
prepared.skipped()[0].reason,
PrefetchSkipReason::NoDestination
);
assert_eq!(prepared.skipped()[1].index, 3);
assert_eq!(prepared.skipped()[1].reason, PrefetchSkipReason::TagSource);
}
#[rstest]
#[case::count(0)]
#[case::input(1)]
#[case::output(2)]
fn every_specification_consumes_the_limits(#[case] resource: usize) {
let mut limits = PrefetchLimits::default();
match resource {
0 => limits.max_specs = 0,
1 => limits.max_input_bytes = 0,
2 => limits.max_output_bytes = 0,
_ => unreachable!(),
}
let result = PrefetchRefspecs::parse(
["refs/heads/main:refs/cache/main"],
limits,
&AtomicBool::new(false),
);
match resource {
0 => assert!(matches!(result, Err(PrefetchRefspecsError::SpecLimit))),
1 => assert!(matches!(result, Err(PrefetchRefspecsError::InputByteLimit))),
2 => assert!(matches!(
result,
Err(PrefetchRefspecsError::OutputByteLimit)
)),
_ => unreachable!(),
}
}
#[test]
fn cancellation_precedes_input_iteration() {
let input =
std::iter::from_fn(|| -> Option<&'static str> { panic!("input must not be visited") });
assert!(matches!(
PrefetchRefspecs::parse(input, PrefetchLimits::default(), &AtomicBool::new(true)),
Err(PrefetchRefspecsError::Cancelled)
));
}
#[test]
fn malformed_skipped_source_is_still_refused() {
let error = PrefetchRefspecs::parse(
["refs/heads/main", "+refs/tags/*:not-full"],
PrefetchLimits::default(),
&AtomicBool::new(false),
)
.unwrap_err();
let PrefetchRefspecsError::Input(source) = error else {
panic!("wrong error: {error:?}");
};
assert_eq!(source.index, 1);
}
#[rstest]
#[case::normal(vec!["+refs/heads/*:refs/remotes/origin/*"])]
#[case::custom(vec!["+refs/heads/*:refs/custom/*", "+refs/tags/*:refs/tags/*"])]
#[case::source_only(vec!["refs/heads/main"])]
#[case::tags_to_custom(vec!["+refs/tags/*:refs/customtags/*"])]
#[case::heads_to_tags(vec!["+refs/heads/*:refs/tags/*"])]
#[case::negative(vec!["+refs/heads/*:refs/remotes/origin/*", "^refs/heads/main"])]
#[case::broad(vec!["+refs/*:refs/cache/*"])]
#[case::broad_negative(vec!["+refs/*:refs/cache/*", "^refs/tags/*"])]
#[case::head(vec!["HEAD:refs/cache/head"])]
fn mapped_destinations_match_git_prefetch(
#[case] specs: Vec<&str>,
#[values(ObjectFormat::Sha1, ObjectFormat::Sha256)] format: ObjectFormat,
) {
let root = tempfile::tempdir().unwrap();
for name in ["source.git", "local.git"] {
oracle::git(
root.path(),
&[
"init",
"--bare",
"--template=",
"--initial-branch=main",
&format!("--object-format={format}"),
name,
],
b"",
);
}
let source = root.path().join("source.git");
let local = root.path().join("local.git");
let tree = oracle::git(&source, &["mktree"], b"");
let tree = std::str::from_utf8(&tree).unwrap().trim();
let commit = oracle::git(&source, &["commit-tree", tree], b"one\n");
let commit = std::str::from_utf8(&commit).unwrap().trim();
oracle::git(&source, &["update-ref", "refs/heads/main", commit], b"");
oracle::git(
&source,
&[
"-c",
"tag.gpgsign=false",
"tag",
"-a",
"-m",
"tag",
"v1",
commit,
],
b"",
);
oracle::git(
&local,
&["config", "remote.origin.url", source.to_str().unwrap()],
b"",
);
for spec in &specs {
oracle::git(
&local,
&["config", "--add", "remote.origin.fetch", spec],
b"",
);
}
oracle::git(
&local,
&[
"-c",
"maintenance.auto=false",
"-c",
"gc.auto=0",
"-c",
"maintenance.autoDetach=false",
"-c",
"gc.autoDetach=false",
"maintenance",
"run",
"--task=prefetch",
],
b"",
);
let names = oracle::git(&local, &["for-each-ref", "--format=%(refname)"], b"");
let mut expected: Vec<_> = names
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(<[u8]>::to_vec)
.collect();
expected.sort();
let advertised = oracle::git(
&source,
&["for-each-ref", "--format=%(objectname) %(refname)"],
b"",
);
let mut sources = vec![RefSource {
name: RefName::HEAD,
id: commit.parse().unwrap(),
}];
for line in advertised
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
{
let separator = line.iter().position(|byte| *byte == b' ').unwrap();
sources.push(RefSource {
name: RefName::new(&line[separator + 1..]).unwrap(),
id: std::str::from_utf8(&line[..separator])
.unwrap()
.parse()
.unwrap(),
});
}
let prepared =
PrefetchRefspecs::parse(&specs, PrefetchLimits::default(), &AtomicBool::new(false))
.unwrap();
let mut actual: Vec<_> = prepared
.refspecs()
.map(&sources)
.unwrap()
.into_iter()
.filter_map(|mapping| mapping.destination.map(|name| name.as_bytes().to_vec()))
.collect();
actual.sort();
assert_eq!(actual, expected);
assert!(!local.join("FETCH_HEAD").exists());
if actual.is_empty() {
let counts = oracle::git(&local, &["count-objects", "-v"], b"");
assert!(String::from_utf8(counts).unwrap().starts_with("count: 0\n"));
}
oracle::git(&local, &["fsck", "--strict"], b"");
}
#[test]
fn cancellation_after_a_batch_precedes_next_input() {
use std::cell::Cell;
use std::sync::atomic::Ordering;
let cancel = AtomicBool::new(false);
let visited = Cell::new(0);
let input = std::iter::from_fn(|| {
visited.set(visited.get() + 1);
cancel.store(true, Ordering::Relaxed);
Some("refs/heads/main:refs/cache/main")
});
assert!(matches!(
PrefetchRefspecs::parse(input, PrefetchLimits::default(), &cancel),
Err(PrefetchRefspecsError::Cancelled)
));
assert_eq!(visited.get(), 64);
}
#[rstest]
#[case::source_only("refs/heads/main")]
#[case::tags("+refs/tags/*:refs/cache/*")]
fn ignored_specifications_still_consume_input(#[case] spec: &str) {
let mut limits = PrefetchLimits::default();
limits.max_input_bytes = 0;
assert!(matches!(
PrefetchRefspecs::parse([spec], limits, &AtomicBool::new(false)),
Err(PrefetchRefspecsError::InputByteLimit)
));
limits.max_input_bytes = usize::MAX;
limits.max_specs = 0;
assert!(matches!(
PrefetchRefspecs::parse([spec], limits, &AtomicBool::new(false)),
Err(PrefetchRefspecsError::SpecLimit)
));
}
#[test]
fn negative_selectors_consume_output_bytes() {
let mut limits = PrefetchLimits::default();
limits.max_output_bytes = 0;
assert!(matches!(
PrefetchRefspecs::parse(["^refs/heads/skip"], limits, &AtomicBool::new(false)),
Err(PrefetchRefspecsError::OutputByteLimit)
));
}
#[test]
fn exact_budgets_succeed_and_one_less_refuses() {
let specs = [
"refs/heads/main:refs/cache/main",
"^refs/heads/skip",
"refs/tags/v1:refs/tags/v1",
];
let mut limits = PrefetchLimits::default();
limits.max_specs = specs.len();
limits.max_input_bytes = specs.iter().map(|value| value.len()).sum();
limits.max_output_bytes = specs[0].len() + b"prefetch/".len() + specs[1].len();
let prepared = PrefetchRefspecs::parse(specs, limits, &AtomicBool::new(false)).unwrap();
assert_eq!(prepared.refspecs().specs().len(), 2);
assert_eq!(prepared.skipped().len(), 1);
for resource in 0..3 {
let mut short = limits;
match resource {
0 => short.max_specs -= 1,
1 => short.max_input_bytes -= 1,
2 => short.max_output_bytes -= 1,
_ => unreachable!(),
}
let result = PrefetchRefspecs::parse(specs, short, &AtomicBool::new(false));
match resource {
0 => assert!(matches!(result, Err(PrefetchRefspecsError::SpecLimit))),
1 => assert!(matches!(result, Err(PrefetchRefspecsError::InputByteLimit))),
2 => assert!(matches!(
result,
Err(PrefetchRefspecsError::OutputByteLimit)
)),
_ => unreachable!(),
}
}
}