use std::sync::Arc;
use std::time::{Duration, Instant};
use gix_object::Kind;
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::seam::{ControlPlane as _, GcState};
use gunnar_proto::RepoPath;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::GitServe;
type Oid = gix_hash::ObjectId;
fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
gix_object::compute_hash(hash, kind, content).expect("hashing an object")
}
fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
let mut body = Vec::new();
for (mode, name, oid) in entries {
body.extend_from_slice(mode.as_bytes());
body.push(b' ');
body.extend_from_slice(name.as_bytes());
body.push(0);
body.extend_from_slice(oid.as_bytes());
}
body
}
fn commit_bytes(tree: Oid, message: &str) -> Vec<u8> {
let who = "Alice <alice@example.com> 1700000000 +0000";
let mut body = format!("tree {}\n", tree.to_hex());
body.push_str(&format!("author {who}\n"));
body.push_str(&format!("committer {who}\n\n"));
body.push_str(message);
body.push('\n');
body.into_bytes()
}
fn plant_a_commit(git: &dyn GitServe, hash: gix_hash::Kind) -> Oid {
let blob = b"the quick brown fox\n".to_vec();
let blob_id = compute(hash, Kind::Blob, &blob);
let tree = tree_bytes(&[("100644", "fox.txt", blob_id)]);
let tree_id = compute(hash, Kind::Tree, &tree);
let commit = commit_bytes(tree_id, "first");
let commit_id = compute(hash, Kind::Commit, &commit);
let mut pb = PackBuilder::new(hash);
for (kind, content) in [
(Kind::Blob, blob),
(Kind::Tree, tree),
(Kind::Commit, commit),
] {
pb.object(kind, content);
}
let fixture = pb.build().expect("building the pack");
git.put_pack(&fixture.bytes).expect("storing the pack");
git.update_ref("refs/heads/main", None, Some(commit_id.as_bytes()))
.expect("pointing main at the commit");
commit_id
}
fn generations(git_dir: &std::path::Path) -> Vec<String> {
fn walk(dir: &std::path::Path, found: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, found);
} else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("repository.g") && name.ends_with(".znippy") {
found.push(name.to_owned());
}
}
}
}
let mut found = Vec::new();
walk(git_dir, &mut found);
found.sort();
found
}
fn settle(plane: &CoreControlPlane, path: &RepoPath) -> gunnar_grpc::seam::GcJob {
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
let job = plane
.gc_status(path)
.expect("gc_status is implemented on the real plane")
.expect("a job that was just enqueued is still known");
if !matches!(job.state, GcState::Queued | GcState::Running) {
return job;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("the gc never settled");
}
#[test]
fn gc_over_the_control_plane_maintains_a_real_repository() {
let dir = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
let repo_ref = RepoRef::new("alice", "swept").expect("legal repo path");
let repo = stores
.create_repo(NewRepo::new(repo_ref).hash_kind(HashKind::Sha256), false)
.expect("create repo");
let store = stores.repo_store(&repo).expect("open repo store");
plant_a_commit(store.serve(), store.hash_kind());
let git_dir = store.git_dir().to_path_buf();
let before = generations(&git_dir);
assert!(
before.is_empty(),
"a repository that has never been maintained has no sealed generation: {before:?}"
);
let path = RepoPath::new("alice", "swept");
let plane = CoreControlPlane::new(Arc::clone(&stores));
assert!(
plane.gc_status(&path).expect("implemented").is_none(),
"a repository nobody has gc'd must have no job at all"
);
let accepted = plane.gc_repo(&path).expect("the gc is accepted");
assert!(
matches!(accepted.state, GcState::Queued | GcState::Running),
"Repos.Gc must return with the job still live, not after running it; got {:?}",
accepted.state
);
assert!(!accepted.job_id.is_empty(), "an accepted job has an id");
let done = settle(&plane, &path);
assert_eq!(done.state, GcState::Done, "the gc failed: {:?}", done.error);
assert_eq!(
done.job_id, accepted.job_id,
"the finished job must be the one that was accepted, not a later run"
);
assert!(
done.finished_at_unix_ms >= done.enqueued_at_unix_ms,
"a job cannot have finished before it was enqueued"
);
assert_eq!(done.queue_position, 0, "a finished job is not waiting");
let after = generations(&git_dir);
assert!(
!after.is_empty(),
"the gc reported DONE and left no sealed generation under {} — nothing was maintained",
git_dir.display()
);
let still = plane
.gc_status(&path)
.expect("implemented")
.expect("a finished job stays readable");
assert_eq!(still.state, GcState::Done);
assert_eq!(still.bytes_reclaimed, done.bytes_reclaimed);
}
#[test]
fn an_unknown_repository_is_refused_and_an_unmaintainable_one_fails_its_job() {
let dir = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
let repo_ref = RepoRef::new("alice", "empty").expect("legal repo path");
stores
.create_repo(NewRepo::new(repo_ref).hash_kind(HashKind::Sha256), false)
.expect("create repo");
let plane = CoreControlPlane::new(Arc::clone(&stores));
let refused = plane
.gc_repo(&RepoPath::new("alice", "no-such-thing"))
.expect_err("a gc of a repository that does not exist must be refused");
let refused = format!("{refused:?}");
assert!(
refused.contains("NotFound"),
"a gc of an unknown repository must be NOT_FOUND, not accepted: {refused}"
);
let path = RepoPath::new("alice", "empty");
plane.gc_repo(&path).expect("a live repository is accepted");
let job = settle(&plane, &path);
assert_eq!(
job.state,
GcState::Failed,
"a repository with no ref cannot be gc'd and the job must say so"
);
let reason = job.error.clone().unwrap_or_default();
assert!(
!reason.is_empty(),
"a FAILED job must carry the reason; 'failed' alone is not actionable"
);
assert!(
reason.contains("alice/empty"),
"the reason must name the repository it is about: {reason}"
);
assert_eq!(job.bytes_reclaimed, 0, "a failed gc reclaimed nothing");
let still = plane
.gc_status(&path)
.expect("implemented")
.expect("a failed job stays readable too");
assert_eq!(still.state, GcState::Failed);
assert_eq!(still.error, job.error);
}