use std::io::{BufRead, Read, Write};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use gunnar_core::{NewRepo, RepoRef};
use gunnar_server::storage::ObjectSet;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_wire::receive_pack::{AllowAll, Config};
use gunnar_wire::ReceivePack;
const FIXTURE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/old-layout-store.bin.gz"
);
const NS: &str = "alice";
const NAME: &str = "old";
fn scratch() -> tempfile::TempDir {
let base = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
std::fs::create_dir_all(&base).expect("scratch base");
tempfile::tempdir_in(base).expect("tempdir")
}
fn pack_dir(root: &Path, out: &Path) {
fn files(root: &Path, dir: &Path, acc: &mut Vec<PathBuf>) {
let mut items: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
let m = std::fs::symlink_metadata(e.path()).unwrap();
if m.is_dir() {
files(root, &e.path(), acc);
} else if m.is_file() {
acc.push(e.path().strip_prefix(root).unwrap().to_path_buf());
}
}
}
let mut all = Vec::new();
files(root, root, &mut all);
let mut gz = flate2::write::GzEncoder::new(
std::fs::File::create(out).unwrap(),
flate2::Compression::best(),
);
for rel in all {
let path = root.join(&rel);
let bytes = std::fs::read(&path).unwrap();
let name = rel.to_string_lossy();
gz.write_all(&(name.len() as u16).to_le_bytes()).unwrap();
gz.write_all(name.as_bytes()).unwrap();
gz.write_all(&std::fs::metadata(&path).unwrap().mode().to_le_bytes())
.unwrap();
gz.write_all(&(bytes.len() as u64).to_le_bytes()).unwrap();
gz.write_all(&bytes).unwrap();
}
gz.finish().unwrap();
}
fn unpack_into(archive: &Path, root: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(archive).unwrap());
loop {
let mut len = [0u8; 2];
match gz.read_exact(&mut len) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) => panic!("{e}"),
}
let mut name = vec![0u8; u16::from_le_bytes(len) as usize];
gz.read_exact(&mut name).unwrap();
let mut mode = [0u8; 4];
gz.read_exact(&mut mode).unwrap();
let mut size = [0u8; 8];
gz.read_exact(&mut size).unwrap();
let mut bytes = vec![0u8; u64::from_le_bytes(size) as usize];
gz.read_exact(&mut bytes).unwrap();
let path = root.join(String::from_utf8(name).unwrap());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &bytes).unwrap();
std::fs::set_permissions(
&path,
std::fs::Permissions::from_mode(u32::from_le_bytes(mode) & 0o7777),
)
.unwrap();
}
}
fn commit_pack(
kind: gix_hash::Kind,
parent: Option<gix_hash::ObjectId>,
text: &str,
) -> (Vec<u8>, gix_hash::ObjectId) {
let blob = format!("{text}\n").into_bytes();
let blob_id = gix_object::compute_hash(kind, gix_object::Kind::Blob, &blob).unwrap();
let mut tree = b"100644 README\0".to_vec();
tree.extend_from_slice(blob_id.as_slice());
let tree_id = gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree).unwrap();
let parent = parent.map(|p| format!("parent {p}\n")).unwrap_or_default();
let commit = format!(
"tree {tree_id}\n{parent}author A <a@example.invalid> 1700000000 +0000\ncommitter A \
<a@example.invalid> 1700000000 +0000\n\n{text}\n"
)
.into_bytes();
let commit_id = gix_object::compute_hash(kind, gix_object::Kind::Commit, &commit).unwrap();
let mut pb = PackBuilder::new(kind);
pb.object(gix_object::Kind::Commit, commit);
pb.object(gix_object::Kind::Tree, tree);
pb.object(gix_object::Kind::Blob, blob);
(pb.build().unwrap().bytes, commit_id)
}
fn push(stores: &ServerStores, path: &RepoRef, text: &str) -> gix_hash::ObjectId {
let repo = stores.catalog().get(path).unwrap().expect("row");
let handle = stores.repo_store(&repo).expect("open");
let kind = handle.hash_kind();
let git_dir = handle.git_dir().to_path_buf();
let old = handle
.serve()
.refs()
.unwrap()
.into_iter()
.find(|r| r.name == "refs/heads/main")
.and_then(|r| r.oid)
.map(|oid| gix_hash::ObjectId::try_from(oid.as_slice()).unwrap());
let (pack, tip) = commit_pack(kind, old, text);
let old_hex = old.unwrap_or_else(|| kind.null()).to_string();
let format = if kind == gix_hash::Kind::Sha1 {
"sha1"
} else {
"sha256"
};
let mut line =
format!("{old_hex} {tip} refs/heads/main\0report-status object-format={format}\n")
.into_bytes();
let mut input = format!("{:04x}", line.len() + 4).into_bytes();
input.append(&mut line);
input.extend_from_slice(b"0000");
input.extend_from_slice(&pack);
let mut reader = std::io::Cursor::new(input);
let engine = stores.engine();
let ingest = |pack: &mut dyn BufRead| -> gunnar_wire::Result<gunnar_wire::pack::IndexedPack> {
let received = engine
.ingest_push(&git_dir, kind, pack, ObjectSet::NotNeeded, None, None)
.map_err(|err| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!("{err:#}")))
})?;
Ok(gunnar_wire::pack::IndexedPack {
num_objects: received.num_objects,
pack_id: gix_hash::ObjectId::try_from(received.pack_id.as_slice()).unwrap(),
index_id: gix_hash::ObjectId::null(kind),
pack_version: gunnar_wire::pack::PackVersion::from_u32(received.version),
hash_kind: kind,
index_path: None,
data_path: None,
keep_path: None,
stored_bytes: Some(received.pack_bytes),
})
};
let mut out = Vec::new();
let outcome = ReceivePack::new(handle.serve(), kind)
.with_config(Config::default())
.serve(&mut reader, &mut out, &ingest, &AllowAll)
.expect("serve")
.expect("a push happened");
assert!(outcome.unpack.is_ok(), "{:?}", outcome.unpack);
tip
}
fn main_ref(stores: &ServerStores, path: &RepoRef) -> Vec<u8> {
let repo = stores.catalog().get(path).unwrap().expect("row");
let handle = stores.repo_store(&repo).expect("open");
handle
.serve()
.refs()
.unwrap()
.into_iter()
.find(|r| r.name == "refs/heads/main")
.expect("refs/heads/main")
.oid
.expect("a direct ref")
}
fn allocated(path: &Path) -> u64 {
std::fs::metadata(path).unwrap().blocks() * 512
}
fn tail_of(stores: &ServerStores, path: &RepoRef) -> PathBuf {
stores
.repo_dir(path)
.unwrap()
.join("gunnar-git.znippy")
.join("objects.tail")
}
fn close_all(stores: ServerStores, paths: &[RepoRef]) {
for p in paths {
stores.close_repo(p);
}
drop(stores);
gunnar_server::storage::shutdown();
}
#[test]
#[ignore = "writes tests/fixtures/old-layout-store.bin.gz; run by hand with the old build"]
fn write_the_old_layout_fixture() {
let out =
PathBuf::from(std::env::var_os("GUNNAR_OLD_LAYOUT_OUT").expect("GUNNAR_OLD_LAYOUT_OUT"));
let dir = scratch();
let stores = ServerStores::open(dir.path()).unwrap();
stores.catalog().create_namespace(NS).unwrap();
let path = RepoRef::new(NS, NAME).unwrap();
stores
.create_repo(NewRepo::new(path.clone()), false)
.unwrap();
let tip = push(&stores, &path, "written by the old layout");
std::thread::sleep(std::time::Duration::from_secs(2));
let tail = tail_of(&stores, &path);
close_all(stores, &[path]);
println!(
"old layout: tip {tip}, objects.tail {} B apparent, {} B allocated",
std::fs::metadata(&tail).unwrap().len(),
allocated(&tail)
);
pack_dir(dir.path(), &out);
}
#[test]
fn a_store_written_by_the_old_layout_opens_and_works() {
let dir = scratch();
unpack_into(Path::new(FIXTURE), dir.path());
let path = RepoRef::new(NS, NAME).unwrap();
let stores = ServerStores::open(dir.path()).expect("the old data directory opens");
let tail = tail_of(&stores, &path);
let old_tail = allocated(&tail);
assert!(
old_tail > 1_000_000,
"fixture: the old layout's redb 2 tail allocates {old_tail} B (the cost this upgrade removes)"
);
let first = main_ref(&stores, &path);
let repo = stores
.catalog()
.get(&path)
.unwrap()
.expect("the catalog row survives");
let handle = stores.repo_store(&repo).expect("the old store opens");
let (kind, body) = handle
.serve()
.read(&first)
.expect("read")
.expect("the old commit is there");
assert_eq!(format!("{kind:?}"), "Commit");
let text = String::from_utf8_lossy(&body);
assert!(text.contains("written by the old layout"), "{text}");
let tree =
gix_hash::ObjectId::from_hex(&text.lines().next().unwrap().as_bytes()["tree ".len()..])
.unwrap();
assert!(
handle.serve().read(tree.as_bytes()).unwrap().is_some(),
"the old tree"
);
drop(handle);
let second = push(&stores, &path, "written by the new layout");
assert_eq!(main_ref(&stores, &path), second.as_bytes());
std::thread::sleep(std::time::Duration::from_secs(1));
close_all(stores, std::slice::from_ref(&path));
let new_tail = allocated(&tail);
assert!(
new_tail <= 100 * 1024,
"the migrated tail allocates {new_tail} B (old: {old_tail} B)"
);
assert!(
!tail.with_file_name("objects.tail.redb4-migrating").exists(),
"no staging file is left"
);
let stores = ServerStores::open(dir.path()).expect("reopen");
assert_eq!(main_ref(&stores, &path), second.as_bytes());
let repo = stores.catalog().get(&path).unwrap().unwrap();
let handle = stores.repo_store(&repo).unwrap();
let (_, body) = handle
.serve()
.read(second.as_bytes())
.unwrap()
.expect("the new commit");
let parent = format!(
"parent {}",
gix_hash::ObjectId::try_from(first.as_slice()).unwrap()
);
assert!(String::from_utf8_lossy(&body).contains(&parent));
assert!(
handle.serve().read(&first).unwrap().is_some(),
"the old commit, after the migration"
);
}