crates/gunnar-bitmap/tests/support/mod.rs
//! Fixture repositories built by stock `git`, so the bitmaps under test are the
//! ones `git` writes and not the ones this crate writes.
//!
//! Nothing here points at a path on the machine this was developed on. A test
//! that decodes only files a particular developer happens to have is a test that
//! silently stops running everywhere else, which is the same failure mode as a
//! guard that cannot go red.
// Each integration test binary compiles this module separately and uses a
// different subset of it, so unused-item warnings here are noise about the
// other binaries rather than about dead code.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::process::Command;
/// Runs `git` and fails loudly on a non-zero exit.
///
/// Checking the *output* rather than trusting the exit code matters: a
/// misspelled subcommand exits non-zero with an empty stdout, and an empty
/// stdout is shaped exactly like a legitimately empty result set.
pub fn git(repo: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap_or_else(|e| panic!("spawn git {args:?}: {e}"));
assert!(
out.status.success(),
"git {args:?} failed with {:?}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("git speaks utf-8 here")
}
/// Runs `git` and returns success plus the combined output, for the cases where
/// failure is the expected result.
pub fn git_try(repo: &Path, args: &[&str]) -> (bool, String) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap_or_else(|e| panic!("spawn git {args:?}: {e}"));
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), text)
}
/// A throwaway repository with a bitmap that stock git wrote.
pub struct Fixture {
_dir: tempfile::TempDir,
/// Path of the repository.
pub repo: PathBuf,
/// Which hash the repository uses.
pub object_hash: gix_hash::Kind,
}
impl Fixture {
/// Build a repository with enough history that the bitmap is interesting:
/// branches that diverge and rejoin, a tag, files that change and files that
/// do not, so the type bitmaps and the XOR chains are all exercised.
pub fn build(commits: usize, object_hash: gix_hash::Kind) -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("work");
std::fs::create_dir_all(&repo).expect("mkdir");
let format = match object_hash {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
assert!(Command::new("git")
.args(["init", "-q", "--object-format", format, "-b", "main"])
.arg(&repo)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git init")
.success());
git(&repo, &["config", "user.email", "t@example.com"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::create_dir_all(repo.join("stable")).expect("mkdir");
std::fs::write(repo.join("stable/never-changes.txt"), "constant\n").expect("write");
for i in 0..commits {
// A file that changes every commit, one directory that churns and
// one that does not: this is what makes tree bitmaps differ from
// blob bitmaps rather than moving together.
std::fs::write(repo.join("churn.txt"), format!("rev {i}\n")).expect("write");
if i % 3 == 0 {
let d = repo.join(format!("dir{}", i % 7));
std::fs::create_dir_all(&d).expect("mkdir");
std::fs::write(d.join("f.txt"), format!("{i}\n")).expect("write");
}
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", &format!("c{i}")]);
if i == commits / 2 {
git(&repo, &["branch", "side"]);
}
}
// A side branch that diverges and merges back, so some commits are
// reachable two ways and the bitmaps genuinely overlap.
if commits > 4 {
git(&repo, &["checkout", "-q", "side"]);
std::fs::write(repo.join("side.txt"), "side\n").expect("write");
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", "side commit"]);
git(&repo, &["checkout", "-q", "main"]);
git(
&repo,
&["merge", "-q", "--no-ff", "-m", "merge side", "side"],
);
}
git(&repo, &["tag", "-a", "-m", "a tag", "v1"]);
// `-b` is what asks for the bitmap; `-a -d` makes it a single pack with
// full closure, which the format requires (BITMAP_OPT_FULL_DAG).
git(&repo, &["repack", "-adb", "-q"]);
Fixture {
_dir: dir,
repo,
object_hash,
}
}
/// Path of the single `.bitmap` in the repository.
pub fn bitmap_path(&self) -> PathBuf {
self.pack_file("bitmap")
}
/// Path of the pack `.idx`.
pub fn idx_path(&self) -> PathBuf {
self.pack_file("idx")
}
fn pack_file(&self, ext: &str) -> PathBuf {
let dir = self.repo.join(".git/objects/pack");
let mut found: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read {dir:?}: {e}"))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == ext))
.collect();
assert_eq!(
found.len(),
1,
"expected exactly one .{ext} in {dir:?}, found {found:?}"
);
found.pop().expect("checked")
}
/// The raw bytes of the bitmap git wrote.
pub fn bitmap_bytes(&self) -> Vec<u8> {
std::fs::read(self.bitmap_path()).expect("read bitmap")
}
/// Map pack positions to object ids through the real `.idx`.
pub fn index(&self) -> gix_pack::index::File {
gix_pack::index::File::at(self.idx_path(), self.object_hash).expect("open idx")
}
/// The pack-position <-> index-position permutation for this pack.
///
/// Bitmap bits are pack positions, so nothing can be looked up without this.
pub fn pack_order(&self, idx: &gix_pack::index::File) -> gunnar_bitmap::PackOrder {
let mut offsets = vec![0u64; idx.num_objects() as usize];
for entry in idx.iter() {
offsets[idx
.lookup(entry.oid.as_ref())
.expect("oid is in its own index") as usize] = entry.pack_offset;
}
gunnar_bitmap::PackOrder::from_index_offsets(&offsets)
}
}
/// Object ids `git rev-list --objects` reports, as a sorted set of hex strings.
///
/// The second column of `--objects` output is a path, which is why only the
/// first token is taken.
pub fn rev_list_objects(repo: &Path, args: &[&str]) -> Vec<String> {
let mut all = vec!["rev-list", "--objects"];
all.extend_from_slice(args);
let out = git(repo, &all);
let mut ids: Vec<String> = out
.lines()
.filter_map(|l| l.split_whitespace().next())
.map(|s| s.to_owned())
.collect();
ids.sort();
ids.dedup();
ids
}