#![cfg(feature = "test-util")]
mod common;
use std::path::Path;
use std::sync::Arc;
use bytes::Bytes;
use git_remote_object_store::object_store::mock::MockStore;
use git_remote_object_store::object_store::{ObjectStore, PutOpts};
use serde_json::Value;
use time::Duration;
use time::OffsetDateTime;
use common::{
drive_in, git, git_available, git_capture, make_seed_repo, make_seed_repo_with_annotated_tag,
make_seed_repo_with_tag_of_tag, s3_url_packchain,
};
fn read_chain(store: &MockStore, prefix: &str) -> Value {
read_chain_for(store, prefix, "refs/heads/main")
}
fn read_chain_for(store: &MockStore, prefix: &str, ref_name: &str) -> Value {
let key = format!("{prefix}/{ref_name}/chain.json");
let bytes = futures::executor::block_on(store.get_bytes(&key)).expect("chain.json must exist");
serde_json::from_slice(&bytes).expect("chain.json must be valid JSON")
}
fn read_path_index(store: &MockStore, prefix: &str) -> Value {
read_path_index_for(store, prefix, "refs/heads/main")
}
fn read_path_index_for(store: &MockStore, prefix: &str, ref_name: &str) -> Value {
let key = format!("{prefix}/{ref_name}/path-index.json");
let bytes =
futures::executor::block_on(store.get_bytes(&key)).expect("path-index.json must exist");
serde_json::from_slice(&bytes).expect("path-index.json must be valid JSON")
}
#[tokio::test]
async fn first_push_writes_pack_idx_baseline_chain_path_index_format_head() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let tip = &shas[0];
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("packchain push should succeed");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/heads/main\n\n",
"wire output: ok line + terminator",
);
let format_body = futures::executor::block_on(store.get_bytes("repo/FORMAT")).unwrap();
assert_eq!(&format_body[..], b"packchain", "FORMAT body");
let head_body = futures::executor::block_on(store.get_bytes("repo/HEAD")).unwrap();
assert_eq!(&head_body[..], b"refs/heads/main", "HEAD body");
let baseline_key = format!("repo/refs/heads/main/{tip}.bundle");
assert!(
store.contains(&baseline_key),
"baseline bundle missing at {baseline_key}",
);
let chain = read_chain(&store, "repo");
assert_eq!(chain["v"], 1, "chain.json schema version");
assert_eq!(chain["tip"], *tip);
assert_eq!(chain["full_at"], *tip, "first push: full_at == tip");
let segments = chain["segments"].as_array().expect("segments array");
assert_eq!(segments.len(), 1, "first push: exactly one segment");
assert_eq!(segments[0]["sha"], *tip);
assert!(
segments[0]["parent_sha"].is_null(),
"first-push segment must have null parent_sha, got {:?}",
segments[0]["parent_sha"],
);
let pack_path = segments[0]["pack"]
.as_str()
.expect("segment.pack is a string");
assert!(pack_path.starts_with("packs/"), "segment.pack: {pack_path}");
#[allow(clippy::case_sensitive_file_extension_comparisons)]
{
assert!(pack_path.ends_with(".pack"), "segment.pack: {pack_path}");
}
let pack_key = format!("repo/{pack_path}");
assert!(
store.contains(&pack_key),
"pack object missing at {pack_key}"
);
let idx_key = format!("repo/{}", pack_path.replace(".pack", ".idx"));
assert!(store.contains(&idx_key), "idx object missing at {idx_key}");
let path_index = read_path_index(&store, "repo");
assert_eq!(path_index["v"], 2);
assert_eq!(path_index["tip"], *tip);
let tree = path_index["tree"]
.as_object()
.expect("tree must be a JSON object");
assert!(
tree.contains_key("f0.txt"),
"path-index tree must include the seed file f0.txt, got keys: {:?}",
tree.keys().collect::<Vec<_>>(),
);
assert!(!store.contains("repo/refs/heads/main/LOCK#.lock"));
}
#[tokio::test]
async fn incremental_push_appends_segment_newest_first() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _initial_shas) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push must succeed");
let chain_after_1 = read_chain(&store, "repo");
let tip_1 = chain_after_1["tip"].as_str().unwrap().to_owned();
assert_eq!(chain_after_1["segments"].as_array().unwrap().len(), 1);
std::fs::write(seed.path().join("f1.txt"), b"second\n").unwrap();
git(&["add", "."], seed.path());
git(
&["commit", "--quiet", "-m", "step2", "--no-gpg-sign"],
seed.path(),
);
let tip_2 = git_capture(&["rev-parse", "HEAD"], seed.path())
.trim()
.to_owned();
assert_ne!(tip_1, tip_2);
let (out2, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("incremental push must succeed");
assert_eq!(
std::str::from_utf8(&out2).unwrap(),
"ok refs/heads/main\n\n"
);
let chain_after_2 = read_chain(&store, "repo");
assert_eq!(chain_after_2["tip"], tip_2, "tip moved to new commit");
assert_eq!(
chain_after_2["full_at"], tip_1,
"full_at preserved (no force / first push)",
);
let segments = chain_after_2["segments"].as_array().unwrap();
assert_eq!(segments.len(), 2, "incremental adds one new segment");
assert_eq!(segments[0]["sha"], tip_2, "segments[0].sha = new tip");
assert_eq!(
segments[0]["parent_sha"], tip_1,
"segments[0].parent_sha = prior tip",
);
assert_eq!(segments[1]["sha"], tip_1, "segments[1] = prior segment");
for (idx, seg) in segments.iter().enumerate() {
let pack = seg["pack"].as_str().expect("segment.pack");
assert!(
store.contains(&format!("repo/{pack}")),
"segments[{idx}].pack must exist at repo/{pack}",
);
}
let path_index = read_path_index(&store, "repo");
assert_eq!(path_index["tip"], tip_2);
}
#[tokio::test]
async fn force_push_collapses_segments_and_replaces_baseline() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(2, "primary");
let tip_1 = git_capture(&["rev-parse", "HEAD~1"], seed.path())
.trim()
.to_owned();
let tip_2 = git_capture(&["rev-parse", "HEAD"], seed.path())
.trim()
.to_owned();
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push");
let baseline_key_2 = format!("repo/refs/heads/main/{tip_2}.bundle");
assert!(store.contains(&baseline_key_2));
git(&["reset", "--hard", &tip_1], seed.path());
std::fs::write(seed.path().join("divergent.txt"), b"x\n").unwrap();
git(&["add", "."], seed.path());
git(
&["commit", "--quiet", "-m", "diverge", "--no-gpg-sign"],
seed.path(),
);
let tip_diverge = git_capture(&["rev-parse", "HEAD"], seed.path())
.trim()
.to_owned();
assert_ne!(tip_diverge, tip_2);
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push +refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("force push");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
let chain = read_chain(&store, "repo");
assert_eq!(chain["tip"], tip_diverge);
assert_eq!(
chain["full_at"], tip_diverge,
"force push resets full_at to new tip",
);
let segments = chain["segments"].as_array().unwrap();
assert_eq!(segments.len(), 1, "force push collapses to one segment");
assert_eq!(segments[0]["sha"], tip_diverge);
assert!(segments[0]["parent_sha"].is_null());
let segment_pack = segments[0]["pack"].as_str().expect("segment.pack");
assert!(
store.contains(&format!("repo/{segment_pack}")),
"force-push pack referenced by chain.json must exist at repo/{segment_pack}",
);
let new_baseline = format!("repo/refs/heads/main/{tip_diverge}.bundle");
assert!(store.contains(&new_baseline));
assert!(
store.contains(&baseline_key_2),
"force push must leave prior baseline in place during grace window",
);
let metas = store.list("repo/gc/").await.unwrap();
let baseline_tomb_count = metas
.iter()
.filter(|m| m.key.starts_with("repo/gc/baseline-tomb-"))
.count();
assert_eq!(
baseline_tomb_count, 1,
"force push must write exactly one baseline tombstone for the prior full_at",
);
}
#[tokio::test]
async fn non_force_push_rejects_when_remote_not_ancestor() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let local_tip = &shas[0];
let (other_seed, other_shas) = make_seed_repo(1, "alt");
let unrelated_tip = &other_shas[0];
assert_ne!(local_tip, unrelated_tip);
drop(other_seed);
let store = Arc::new(MockStore::new());
let chain_body = serde_json::json!({
"v": 1,
"tip": unrelated_tip,
"full_at": unrelated_tip,
"segments": [],
});
store.insert(
"repo/refs/heads/main/chain.json",
Bytes::from(serde_json::to_vec(&chain_body).unwrap()),
);
store.insert("repo/FORMAT", Bytes::from_static(b"packchain"));
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("push should refuse, not abort");
let text = std::str::from_utf8(&out).unwrap();
assert_eq!(
text, "error refs/heads/main \"remote ref is not ancestor of refs/heads/main.\"?\n\n",
"got {text:?}",
);
}
#[tokio::test]
async fn lock_contention_returns_error_outcome() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
store.insert_with(
"repo/refs/heads/main/LOCK#.lock",
Bytes::new(),
OffsetDateTime::now_utc(),
PutOpts::default(),
);
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("push should refuse, not abort");
let text = std::str::from_utf8(&out).unwrap();
let ttl_secs: u64 = std::env::var("GIT_REMOTE_OBJECT_STORE_LOCK_TTL_SECONDS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(git_remote_object_store::protocol::push::DEFAULT_LOCK_TTL_SECONDS);
let expected = format!(
"error refs/heads/main \"failed to acquire ref lock at \
repo/refs/heads/main/LOCK#.lock. Another client may be pushing. \
If this persists beyond {ttl_secs}s, run git-remote-object-store \
doctor to inspect and optionally clear stale locks.\"?\n\n",
);
assert_eq!(text, expected, "got {text:?}");
assert!(store.contains("repo/refs/heads/main/LOCK#.lock"));
}
#[tokio::test]
async fn stale_lock_is_recovered() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
store.insert_with(
"repo/refs/heads/main/LOCK#.lock",
Bytes::new(),
OffsetDateTime::now_utc() - Duration::seconds(120),
PutOpts::default(),
);
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("push should succeed via stale-lock recovery");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert!(store.contains("repo/refs/heads/main/chain.json"));
}
#[tokio::test]
async fn idempotent_same_sha_push_short_circuits() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push");
let chain_v1 = read_chain(&store, "repo");
let pack_count_v1 = store
.keys()
.into_iter()
.filter(|k| k.starts_with("repo/packs/"))
.count();
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("idempotent push");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
let chain_v2 = read_chain(&store, "repo");
assert_eq!(chain_v1, chain_v2, "chain.json unchanged");
let pack_count_v2 = store
.keys()
.into_iter()
.filter(|k| k.starts_with("repo/packs/"))
.count();
assert_eq!(
pack_count_v1, pack_count_v2,
"no new pack files written on a same-SHA push",
);
}
#[tokio::test]
async fn format_mismatch_rejected_at_connect_time() {
use git_remote_object_store::protocol::backend::{self, BackendError, BackendKind};
use git_remote_object_store::url::StorageEngine;
let store = Arc::new(MockStore::new());
store.insert("repo/FORMAT", Bytes::from_static(b"bundle"));
let err = backend::validate_format(
BackendKind::S3,
store.as_ref(),
"repo",
Some(StorageEngine::Packchain),
)
.await
.expect_err("format mismatch must surface as BackendError");
assert!(
matches!(err, BackendError::EngineMismatch { .. }),
"expected EngineMismatch, got {err:?}",
);
}
#[tokio::test]
async fn delete_remote_ref_removes_chain_and_path_index() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push");
assert!(store.contains("repo/refs/heads/main/chain.json"));
assert!(store.contains("repo/refs/heads/main/path-index.json"));
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push :refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("delete push");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert!(
!store.contains("repo/refs/heads/main/chain.json"),
"chain.json must be deleted",
);
assert!(
!store.contains("repo/refs/heads/main/path-index.json"),
"path-index.json must be deleted",
);
let pack_keys: Vec<_> = store
.keys()
.into_iter()
.filter(|k| k.starts_with("repo/packs/"))
.collect();
assert!(
!pack_keys.is_empty(),
"pack files must remain after delete (orphans reaped by Phase 5 GC)",
);
}
#[tokio::test]
async fn first_push_pins_format_marker() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push");
let body = futures::executor::block_on(store.get_bytes("repo/FORMAT")).unwrap();
assert_eq!(&body[..], b"packchain");
}
#[tokio::test]
async fn force_push_baseline_cleanup_failure_does_not_fail_push() {
use git_remote_object_store::object_store::mock::Fault;
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(2, "primary");
let tip_1 = git_capture(&["rev-parse", "HEAD~1"], seed.path())
.trim()
.to_owned();
let tip_2 = git_capture(&["rev-parse", "HEAD"], seed.path())
.trim()
.to_owned();
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push");
let baseline_key_2 = format!("repo/refs/heads/main/{tip_2}.bundle");
assert!(store.contains(&baseline_key_2));
git(&["reset", "--hard", &tip_1], seed.path());
std::fs::write(seed.path().join("divergent.txt"), b"x\n").unwrap();
git(&["add", "."], seed.path());
git(
&["commit", "--quiet", "-m", "diverge", "--no-gpg-sign"],
seed.path(),
);
store.arm(Fault::NetworkOnPutBytesPrefix {
prefix: "repo/gc/baseline-tomb-".to_owned(),
});
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push +refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("force push must not fail on baseline-cleanup error");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/heads/main\n\n",
"wire output must still be `ok` even though baseline cleanup failed",
);
assert!(
store.contains(&baseline_key_2),
"old baseline must remain when tombstone fault prevented cleanup",
);
assert_eq!(store.pending_faults(), 0);
}
#[tokio::test]
async fn concurrent_different_refs_share_format_and_head() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first ref push");
let head_after_main = futures::executor::block_on(store.get_bytes("repo/HEAD")).unwrap();
assert_eq!(&head_after_main[..], b"refs/heads/main");
git(&["branch", "dev"], seed.path());
let (_, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/dev:refs/heads/dev\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("second ref push");
let format_keys: Vec<_> = store
.keys()
.into_iter()
.filter(|k| k == "repo/FORMAT")
.collect();
assert_eq!(format_keys.len(), 1, "FORMAT must be a single key");
let format_body = futures::executor::block_on(store.get_bytes("repo/FORMAT")).unwrap();
assert_eq!(&format_body[..], b"packchain");
let head_after_dev = futures::executor::block_on(store.get_bytes("repo/HEAD")).unwrap();
assert_eq!(
&head_after_dev[..],
b"refs/heads/main",
"HEAD must remain at the first ref pushed (put_if_absent semantics)",
);
assert!(store.contains("repo/refs/heads/main/chain.json"));
assert!(store.contains("repo/refs/heads/dev/chain.json"));
}
#[allow(dead_code)]
fn touch(path: &Path) {
std::fs::write(path, b"").unwrap();
}
fn pack_idx_oids(store: &MockStore, prefix: &str, pack_path_in_chain: &str) -> Vec<String> {
let idx_relative = pack_path_in_chain.replace(".pack", ".idx");
let key = format!("{prefix}/{idx_relative}");
let bytes = futures::executor::block_on(store.get_bytes(&key)).expect(".idx must exist");
let tmp = tempfile::tempdir().expect("tempdir");
let idx_path = tmp.path().join("scan.idx");
std::fs::write(&idx_path, &bytes).unwrap();
let idx = gix_pack::index::File::at(&idx_path, gix_hash::Kind::Sha1).expect("parse idx");
let mut oids = Vec::with_capacity(idx.num_objects() as usize);
for entry in idx.iter() {
oids.push(entry.oid.to_string());
}
oids
}
#[tokio::test]
async fn first_push_of_annotated_tag_lands_pack_with_tag_object() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, commit_sha, tag_sha) = make_seed_repo_with_annotated_tag("primary", "v1");
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/v1:refs/tags/v1\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("annotated tag push must succeed");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/tags/v1\n\n",
"wire output: ok line for tag ref + terminator",
);
let chain = read_chain_for(&store, "repo", "refs/tags/v1");
assert_eq!(
chain["tip"], tag_sha,
"chain.tip must be the tag OID, not the underlying commit",
);
let segments = chain["segments"].as_array().unwrap();
let pack_path = segments[0]["pack"].as_str().unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
assert!(
oids.iter().any(|o| o == &tag_sha),
"segment-0 pack must include the tag object {tag_sha}; got {oids:?}",
);
assert!(
oids.iter().any(|o| o == &commit_sha),
"segment-0 pack must include the commit target {commit_sha}; got {oids:?}",
);
}
#[tokio::test]
async fn first_push_of_tag_of_tag_lands_full_chain_in_pack() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, commit_sha, inner_sha, outer_sha) =
make_seed_repo_with_tag_of_tag("primary", "inner", "outer");
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/outer:refs/tags/outer\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("tag-of-tag push must succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/tags/outer\n\n");
let chain = read_chain_for(&store, "repo", "refs/tags/outer");
let pack_path = chain["segments"].as_array().unwrap()[0]["pack"]
.as_str()
.unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
for needed in [&outer_sha, &inner_sha, &commit_sha] {
assert!(
oids.iter().any(|o| o == needed),
"tag-of-tag pack must contain {needed}; got {oids:?}",
);
}
}
#[tokio::test]
async fn force_retag_replaces_pack_with_new_tag_object() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _commit_a, tag_v1_a) = make_seed_repo_with_annotated_tag("primary", "v1");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/v1:refs/tags/v1\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first tag push must succeed");
std::fs::write(seed.path().join("f1.txt"), b"second\n").unwrap();
git(&["add", "."], seed.path());
git(
&["commit", "--quiet", "-m", "step2", "--no-gpg-sign"],
seed.path(),
);
let commit_b = git_capture(&["rev-parse", "HEAD"], seed.path())
.trim()
.to_owned();
git(
&["tag", "-af", "v1", "-m", "release v1 again", &commit_b],
seed.path(),
);
let tag_v1_b = git_capture(&["rev-parse", "v1"], seed.path())
.trim()
.to_owned();
assert_ne!(tag_v1_a, tag_v1_b, "retag must produce a new tag OID");
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push +refs/tags/v1:refs/tags/v1\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("force-retag must succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/tags/v1\n\n");
let chain = read_chain_for(&store, "repo", "refs/tags/v1");
assert_eq!(
chain["tip"], tag_v1_b,
"chain.tip must be the new tag OID after force-retag",
);
let segments = chain["segments"].as_array().unwrap();
assert_eq!(
segments.len(),
1,
"force-retag must collapse to a single segment",
);
let pack_path = segments[0]["pack"].as_str().unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
assert!(
oids.iter().any(|o| o == &tag_v1_b),
"new pack must include the new tag {tag_v1_b}; got {oids:?}",
);
assert!(
oids.iter().any(|o| o == &commit_b),
"new pack must include the new commit {commit_b}; got {oids:?}",
);
assert!(
!oids.iter().any(|o| o == &tag_v1_a),
"new pack must NOT include the old tag {tag_v1_a}; got {oids:?}",
);
}
#[tokio::test]
async fn repushing_same_annotated_tag_is_idempotent() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _commit_sha, _tag_sha) = make_seed_repo_with_annotated_tag("primary", "v1");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/v1:refs/tags/v1\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push must succeed");
let chain_1 = read_chain_for(&store, "repo", "refs/tags/v1").to_string();
let key_count_1 = store.keys().len();
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/v1:refs/tags/v1\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("idempotent re-push must succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/tags/v1\n\n");
let chain_2 = read_chain_for(&store, "repo", "refs/tags/v1").to_string();
let key_count_2 = store.keys().len();
assert_eq!(chain_1, chain_2, "chain.json unchanged on idempotent push");
assert_eq!(
key_count_1, key_count_2,
"no new bucket keys created on idempotent push",
);
}
fn mktag_pointing_at(seed_dir: &Path, target_oid: &str, kind: &str, tag_name: &str) -> String {
use std::io::Write as _;
let body = format!(
"object {target_oid}\n\
type {kind}\n\
tag {tag_name}\n\
tagger Test <test@example.com> 0 +0000\n\
\n\
pointing-at-{kind}\n",
);
let mktag = std::process::Command::new("git")
.args(["mktag"])
.current_dir(seed_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn git mktag");
mktag
.stdin
.as_ref()
.unwrap()
.write_all(body.as_bytes())
.unwrap();
let out = mktag.wait_with_output().expect("git mktag");
assert!(
out.status.success(),
"git mktag failed: {}",
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout).unwrap().trim().to_owned()
}
#[tokio::test]
async fn first_push_of_tag_pointing_to_blob_lands_pack_with_tag_and_blob() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
std::fs::write(seed.path().join("blob-target"), b"data\n").unwrap();
let blob_oid = git_capture(&["hash-object", "-w", "blob-target"], seed.path())
.trim()
.to_owned();
let tag_sha = mktag_pointing_at(seed.path(), &blob_oid, "blob", "blob-tag");
git(&["update-ref", "refs/tags/blob-tag", &tag_sha], seed.path());
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/blob-tag:refs/tags/blob-tag\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("blob-tag push must succeed");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/tags/blob-tag\n\n",
"wire output: ok line for blob-tag ref + terminator",
);
let chain = read_chain_for(&store, "repo", "refs/tags/blob-tag");
assert_eq!(
chain["tip"], tag_sha,
"chain.tip must be the tag OID, not the blob",
);
let segments = chain["segments"].as_array().unwrap();
let pack_path = segments[0]["pack"].as_str().unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
assert_eq!(
oids.len(),
2,
"blob-tipped pack must contain exactly the blob + the tag; got {oids:?}",
);
assert!(
oids.iter().any(|o| o == &tag_sha),
"pack must include the tag {tag_sha}; got {oids:?}",
);
assert!(
oids.iter().any(|o| o == &blob_oid),
"pack must include the leaf blob {blob_oid}; got {oids:?}",
);
let path_index_key = "repo/refs/tags/blob-tag/path-index.json";
assert!(
!store.contains(path_index_key),
"blob-tipped chains must not write a path-index.json",
);
}
#[tokio::test]
async fn first_push_of_tag_pointing_to_tree_lands_pack_with_tree_closure() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
let tree_oid = git_capture(&["rev-parse", "HEAD^{tree}"], seed.path())
.trim()
.to_owned();
let tag_sha = mktag_pointing_at(seed.path(), &tree_oid, "tree", "tree-tag");
git(&["update-ref", "refs/tags/tree-tag", &tag_sha], seed.path());
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/tree-tag:refs/tags/tree-tag\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("tree-tag push must succeed");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/tags/tree-tag\n\n",
);
let chain = read_chain_for(&store, "repo", "refs/tags/tree-tag");
assert_eq!(chain["tip"], tag_sha);
let segments = chain["segments"].as_array().unwrap();
let pack_path = segments[0]["pack"].as_str().unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
assert!(oids.iter().any(|o| o == &tag_sha), "tag must be in pack");
assert!(oids.iter().any(|o| o == &tree_oid), "tree must be in pack");
let blob_oid = git_capture(&["rev-parse", "HEAD:f0.txt"], seed.path())
.trim()
.to_owned();
assert!(
oids.iter().any(|o| o == &blob_oid),
"tree blob f0.txt {blob_oid} must be in pack; got {oids:?}",
);
let path_index = read_path_index_for(&store, "repo", "refs/tags/tree-tag");
assert_eq!(path_index["v"], 2);
assert_eq!(path_index["tip"], tag_sha);
let tree = path_index["tree"]
.as_object()
.expect("path-index tree must be a JSON object");
assert!(
tree.contains_key("f0.txt"),
"tree-tip path-index must include the seed file",
);
}
#[tokio::test]
async fn first_push_of_tag_of_tag_of_tree_round_trips_full_chain() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
let tree_oid = git_capture(&["rev-parse", "HEAD^{tree}"], seed.path())
.trim()
.to_owned();
let inner_tag = mktag_pointing_at(seed.path(), &tree_oid, "tree", "inner");
let outer_tag = mktag_pointing_at(seed.path(), &inner_tag, "tag", "outer");
git(&["update-ref", "refs/tags/outer", &outer_tag], seed.path());
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/outer:refs/tags/outer\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("tag-of-tag-of-tree push must succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/tags/outer\n\n");
let chain = read_chain_for(&store, "repo", "refs/tags/outer");
assert_eq!(chain["tip"], outer_tag);
let pack_path = chain["segments"].as_array().unwrap()[0]["pack"]
.as_str()
.unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
let blob_oid = git_capture(&["rev-parse", "HEAD:f0.txt"], seed.path())
.trim()
.to_owned();
for needed in [&outer_tag, &inner_tag, &tree_oid, &blob_oid] {
assert!(
oids.iter().any(|o| o == needed),
"pack must contain {needed}; got {oids:?}",
);
}
}
#[tokio::test]
async fn first_push_of_bare_blob_ref_lands_pack_with_blob_only() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
std::fs::write(seed.path().join("bare-blob"), b"bare\n").unwrap();
let blob_oid = git_capture(&["hash-object", "-w", "bare-blob"], seed.path())
.trim()
.to_owned();
git(
&["update-ref", "refs/notes/special", &blob_oid],
seed.path(),
);
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/notes/special:refs/notes/special\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("bare-blob push must succeed");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/notes/special\n\n",
);
let chain = read_chain_for(&store, "repo", "refs/notes/special");
assert_eq!(chain["tip"], blob_oid);
let pack_path = chain["segments"].as_array().unwrap()[0]["pack"]
.as_str()
.unwrap();
let oids = pack_idx_oids(&store, "repo", pack_path);
assert_eq!(
oids.len(),
1,
"bare-blob pack must contain exactly the blob; got {oids:?}",
);
assert_eq!(oids[0], blob_oid);
assert!(
!store.contains("repo/refs/notes/special/path-index.json"),
"blob-tipped chains must not write path-index.json",
);
}
#[tokio::test]
async fn force_push_from_commit_to_blob_tip_removes_stale_path_index() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _shas) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (_, r1) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r1.expect("first push must succeed");
let path_index_key = "repo/refs/heads/main/path-index.json";
assert!(
store.contains(path_index_key),
"commit-tipped first push must write path-index.json",
);
std::fs::write(seed.path().join("blob-target"), b"force-target\n").unwrap();
let blob_oid = git_capture(&["hash-object", "-w", "blob-target"], seed.path())
.trim()
.to_owned();
git(&["update-ref", "refs/blob-ref", &blob_oid], seed.path());
let (out, r2) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push +refs/blob-ref:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
r2.expect("force-push to blob tip must succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
let chain = read_chain(&store, "repo");
assert_eq!(
chain["tip"], blob_oid,
"force-push must rewrite chain.tip to the blob OID",
);
assert!(
!store.contains(path_index_key),
"force-push to blob tip must remove the stale path-index.json from \
the prior commit-tipped push",
);
}