#![cfg(feature = "test-util")]
mod common;
use std::sync::Arc;
use bytes::Bytes;
use git_remote_object_store::PackchainError;
use git_remote_object_store::object_store::ObjectStore;
use git_remote_object_store::object_store::mock::MockStore;
use git_remote_object_store::protocol::ProtocolError;
use git_remote_object_store::protocol::fetch::FetchError;
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 dst_has_object(dst: &std::path::Path, sha: &str) -> bool {
let output = std::process::Command::new("git")
.args(["cat-file", "-e", sha])
.current_dir(dst)
.output()
.expect("spawn git cat-file");
output.status.success()
}
fn make_empty_dst() -> tempfile::TempDir {
let dst = tempfile::tempdir().expect("dst tempdir");
git(&["init", "--quiet", "--initial-branch=main"], dst.path());
git(&["config", "user.email", "test@example.com"], dst.path());
git(&["config", "user.name", "Test"], dst.path());
git(&["config", "commit.gpgsign", "false"], dst.path());
dst
}
async fn push_n_commits_into(
store: &Arc<MockStore>,
n: usize,
label: &str,
) -> (tempfile::TempDir, Vec<String>) {
let (seed, shas) = make_seed_repo(n, label);
let (_, 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 must succeed");
(seed, shas)
}
#[tokio::test]
async fn fetch_into_empty_repo_after_first_push_lands_tip_object() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let store = Arc::new(MockStore::new());
let (_seed, shas) = push_n_commits_into(&store, 1, "primary").await;
let tip = &shas[0];
let dst = make_empty_dst();
let fetch_script = format!("fetch {tip} refs/heads/main\n\n");
let (_out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script,
dst.path().to_path_buf(),
)
.await;
result.expect("packchain fetch into empty dst must succeed");
assert!(
dst_has_object(dst.path(), tip),
"tip {tip} must be reachable in dst after fetch",
);
}
#[tokio::test]
async fn clone_walks_two_segment_chain_and_lands_both_commits() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas1) = 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 tip_1 = &shas1[0];
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();
let (_, 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("second push");
let dst = make_empty_dst();
let fetch_script_2 = format!("fetch {tip_2} refs/heads/main\n\n");
let (_out, fetch_result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script_2,
dst.path().to_path_buf(),
)
.await;
fetch_result.expect("fetch tip_2 into empty dst");
assert!(dst_has_object(dst.path(), tip_1), "tip_1 reachable");
assert!(dst_has_object(dst.path(), &tip_2), "tip_2 reachable");
}
#[tokio::test]
async fn fetched_refs_dedupes_across_batches() {
use git_remote_object_store::protocol::run;
use git_remote_object_store::url::StorageEngine;
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let store = Arc::new(MockStore::new());
let (_seed, shas) = push_n_commits_into(&store, 1, "primary").await;
let tip = &shas[0];
let chain_key_owned = "repo/refs/heads/main/chain.json".to_owned();
let dst = make_empty_dst();
let dst_path = dst.path().to_path_buf();
let remote = s3_url_packchain(Some("repo"));
let store_for_run: Arc<dyn ObjectStore> = Arc::clone(&store) as _;
let (client_side, helper_side) = tokio::io::duplex(64 * 1024);
let (helper_in, helper_out) = tokio::io::split(helper_side);
let (mut client_reader, mut client_writer) = tokio::io::split(client_side);
let run_task = tokio::spawn(async move {
run(
remote,
store_for_run,
StorageEngine::Packchain,
BufReader::new(helper_in),
helper_out,
None,
dst_path,
)
.await
});
client_writer
.write_all(format!("fetch {tip} refs/heads/main\n\n").as_bytes())
.await
.unwrap();
let mut buf = [0u8; 1];
client_reader.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"\n", "batch 1 should emit the terminator");
store
.delete(&chain_key_owned)
.await
.expect("chain.json must be present from setup");
client_writer
.write_all(format!("fetch {tip} refs/heads/main\n\n").as_bytes())
.await
.unwrap();
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_reader.read_exact(&mut buf),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(read_err)) => {
client_writer.shutdown().await.ok();
let run_outcome = run_task.await;
panic!(
"batch 2 emitted no terminator (read error: {read_err}); run() outcome: \
{run_outcome:?} — dedup likely broken: helper attempted a forbidden \
re-read of the deleted chain.json"
);
}
Err(elapsed) => {
panic!("batch 2 read timed out after {elapsed} — helper appears stuck")
}
}
assert_eq!(&buf, b"\n", "batch 2 should emit the terminator");
client_writer.shutdown().await.unwrap();
let result = run_task.await.unwrap();
result.expect("second batch must short-circuit via FetchedRefs even though chain.json is gone");
assert!(dst_has_object(dst.path(), tip), "tip must remain reachable");
}
#[tokio::test]
async fn fetch_from_empty_bucket_surfaces_chain_absent_error() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let store = Arc::new(MockStore::new());
let dst = make_empty_dst();
let bogus_sha = "1111111111111111111111111111111111111111";
let fetch_script = format!("fetch {bogus_sha} refs/heads/main\n\n");
let (_out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script,
dst.path().to_path_buf(),
)
.await;
let err = result.expect_err("fetch from empty bucket must error");
assert!(
matches!(
err,
ProtocolError::Fetch(FetchError::Packchain(PackchainError::ChainAbsent { .. }))
),
"expected ChainAbsent, got {err:?}",
);
}
#[tokio::test]
async fn fetch_surfaces_pack_missing_when_chain_references_absent_pack() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let store = Arc::new(MockStore::new());
let bogus_tip = "1111111111111111111111111111111111111111";
let bogus_pack = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let chain_body = serde_json::json!({
"v": 1,
"tip": bogus_tip,
"full_at": bogus_tip,
"segments": [{
"sha": bogus_tip,
"parent_sha": serde_json::Value::Null,
"pack": format!("packs/{bogus_pack}.pack"),
"bytes": 1024,
}],
});
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 dst = make_empty_dst();
let fetch_script = format!("fetch {bogus_tip} refs/heads/main\n\n");
let (_out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script,
dst.path().to_path_buf(),
)
.await;
let err = result.expect_err("missing pack key must error");
match err {
ProtocolError::Fetch(FetchError::Packchain(PackchainError::PackMissing { key })) => {
assert!(
key.contains(bogus_pack),
"error key must name the missing pack, got {key}",
);
}
other => panic!("expected PackMissing, got {other:?}"),
}
}
#[tokio::test]
async fn shallow_fetch_depth_one_skips_baseline_download() {
use git_remote_object_store::object_store::mock::Fault;
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let store = Arc::new(MockStore::new());
let (_seed, shas) = push_n_commits_into(&store, 1, "primary").await;
let tip = &shas[0];
let baseline_key = format!("repo/refs/heads/main/{tip}.bundle");
store.arm(Fault::PreconditionFailedOnGetToFile {
key: baseline_key.clone(),
});
let dst = make_empty_dst();
let fetch_script = format!("option depth 1\nfetch {tip} refs/heads/main\n\n");
let (_out, result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script,
dst.path().to_path_buf(),
)
.await;
result.expect("shallow fetch must succeed without touching baseline");
assert!(
dst_has_object(dst.path(), tip),
"shallow fetch must land the tip",
);
let shallow_path = dst.path().join(".git/shallow");
assert!(
shallow_path.exists(),
".git/shallow must be created on shallow fetch",
);
assert_eq!(
store.pending_faults(),
1,
"shallow fetch must NOT download the baseline at depth=1",
);
}
#[tokio::test]
async fn fetch_round_trip_of_annotated_tag_resolves_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 (_, push_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;
push_result.expect("push must succeed");
let dst = make_empty_dst();
let fetch_script = format!("fetch {tag_sha} refs/tags/v1\n\n");
let (_, fetch_result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script,
dst.path().to_path_buf(),
)
.await;
fetch_result.expect("packchain fetch of tag must succeed");
assert!(
dst_has_object(dst.path(), &tag_sha),
"tag {tag_sha} must be reachable in dst",
);
let kind = std::process::Command::new("git")
.args(["cat-file", "-t", &tag_sha])
.current_dir(dst.path())
.output()
.expect("spawn git cat-file -t");
assert!(
kind.status.success(),
"git cat-file -t failed: {}",
String::from_utf8_lossy(&kind.stderr),
);
let kind_text = String::from_utf8(kind.stdout).unwrap();
assert_eq!(
kind_text.trim(),
"tag",
"tag OID must decode as a tag object",
);
let body = std::process::Command::new("git")
.args(["cat-file", "-p", &tag_sha])
.current_dir(dst.path())
.output()
.expect("spawn git cat-file -p");
let body_text = String::from_utf8(body.stdout).unwrap();
assert!(
body_text.contains("release"),
"annotation body must round-trip; got {body_text:?}",
);
}
#[tokio::test]
async fn fetch_round_trip_of_tag_of_tag_resolves_full_chain() {
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 (_, push_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;
push_result.expect("push must succeed");
let dst = make_empty_dst();
let fetch_script = format!("fetch {outer_sha} refs/tags/outer\n\n");
let (_, fetch_result) = drive_in(
s3_url_packchain(Some("repo")),
Arc::clone(&store) as Arc<dyn ObjectStore>,
&fetch_script,
dst.path().to_path_buf(),
)
.await;
fetch_result.expect("packchain fetch of tag-of-tag must succeed");
for needed in [&outer_sha, &inner_sha, &commit_sha] {
assert!(
dst_has_object(dst.path(), needed),
"{needed} must be reachable in dst after tag-of-tag fetch",
);
}
for tag in [&outer_sha, &inner_sha] {
let kind = std::process::Command::new("git")
.args(["cat-file", "-t", tag])
.current_dir(dst.path())
.output()
.expect("spawn git cat-file");
let text = String::from_utf8(kind.stdout).unwrap();
assert_eq!(text.trim(), "tag", "{tag} must decode as a tag object");
}
}