#![cfg(feature = "test-util")]
mod common;
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 time::Duration;
use time::OffsetDateTime;
use common::{
drive_in, git, git_available, make_seed_repo, make_seed_repo_with_annotated_tag,
s3_url_with_zip,
};
async fn assert_bundle_engine_delete_swept_clean(store: &MockStore, prefix: &str, ref_path: &str) {
let ref_prefix = format!("{prefix}/{ref_path}/");
let remaining = store.list(&ref_prefix).await.unwrap();
assert!(
remaining.is_empty(),
"ref prefix {ref_prefix:?} must be empty after delete: {:?}",
remaining.iter().map(|m| &m.key).collect::<Vec<_>>(),
);
let gc_prefix = format!("{prefix}/gc/");
let gc_keys = store.list(&gc_prefix).await.unwrap();
assert!(
gc_keys.is_empty(),
"bundle-engine delete must not write a tombstone under {gc_prefix:?}: {:?}",
gc_keys.iter().map(|m| &m.key).collect::<Vec<_>>(),
);
}
#[tokio::test]
async fn push_to_empty_remote_uploads_bundle_and_seeds_head() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let sha = &shas[0];
let store = Arc::new(MockStore::new());
let script = "push refs/heads/main:refs/heads/main\n\n";
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
script,
seed.path().to_path_buf(),
)
.await;
result.expect("push should succeed");
assert_eq!(
std::str::from_utf8(&out).unwrap(),
"ok refs/heads/main\n\n",
"ok line + terminator",
);
assert!(store.contains(&format!("repo/refs/heads/main/{sha}.bundle")));
let head = store
.keys()
.into_iter()
.find(|k| k == "repo/HEAD")
.expect("HEAD seeded");
let head_body = futures::executor::block_on(store.get_bytes(&head)).unwrap();
assert_eq!(&head_body[..], b"refs/heads/main");
assert!(!store.contains("repo/refs/heads/main/LOCK#.lock"));
}
#[tokio::test]
async fn push_fast_forward_replaces_old_bundle() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(2, "primary");
let old_sha = &shas[0];
let new_sha = &shas[1];
let store = Arc::new(MockStore::new());
store.insert(
format!("repo/refs/heads/main/{old_sha}.bundle"),
Bytes::from_static(b"old"),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert!(store.contains(&format!("repo/refs/heads/main/{new_sha}.bundle")));
assert!(
store.contains(&format!("repo/refs/heads/main/{old_sha}.bundle")),
"old bundle must remain during the grace window",
);
let metas = store.list("repo/gc/").await.unwrap();
let tombstones: Vec<_> = metas
.iter()
.filter(|m| m.key.starts_with("repo/gc/baseline-tomb-"))
.collect();
assert_eq!(
tombstones.len(),
1,
"exactly one baseline tombstone must be written; got {:?}",
tombstones.iter().map(|m| &m.key).collect::<Vec<_>>(),
);
let body = store.get_bytes(&tombstones[0].key).await.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["ref_name"].as_str(), Some("refs/heads/main"));
assert_eq!(
parsed["sha"].as_str(),
Some(old_sha.as_str()),
"tombstone must name the prior bundle's SHA",
);
}
#[tokio::test]
async fn push_non_fast_forward_is_rejected_without_force() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let (other_seed, other_shas) = make_seed_repo(1, "alt");
let unrelated_sha = &other_shas[0];
assert_ne!(&shas[0], unrelated_sha);
drop(other_seed);
let store = Arc::new(MockStore::new());
store.insert(
format!("repo/refs/heads/main/{unrelated_sha}.bundle"),
Bytes::from_static(b"x"),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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 produce a refusal, 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:?}",
);
assert!(store.contains(&format!("repo/refs/heads/main/{unrelated_sha}.bundle")));
}
#[tokio::test]
async fn force_push_overwrites_unrelated_remote() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let local_sha = &shas[0];
let (other_seed, other_shas) = make_seed_repo(1, "alt");
let unrelated_sha = &other_shas[0];
drop(other_seed);
let store = Arc::new(MockStore::new());
store.insert(
format!("repo/refs/heads/main/{unrelated_sha}.bundle"),
Bytes::from_static(b"x"),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push +refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("force push should succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert!(store.contains(&format!("repo/refs/heads/main/{local_sha}.bundle")));
assert!(
store.contains(&format!("repo/refs/heads/main/{unrelated_sha}.bundle")),
"force-pushed-over bundle must remain readable during the grace window",
);
let metas = store.list("repo/gc/").await.unwrap();
let tombstones: Vec<_> = metas
.iter()
.filter(|m| m.key.starts_with("repo/gc/baseline-tomb-"))
.collect();
assert_eq!(
tombstones.len(),
1,
"exactly one baseline tombstone must be written; got {:?}",
tombstones.iter().map(|m| &m.key).collect::<Vec<_>>(),
);
let body = store.get_bytes(&tombstones[0].key).await.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["ref_name"].as_str(), Some("refs/heads/main"));
assert_eq!(
parsed["sha"].as_str(),
Some(unrelated_sha.as_str()),
"tombstone must name the prior (force-pushed-over) bundle's SHA",
);
}
#[tokio::test]
async fn force_push_protected_falls_back_to_ancestor_check() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let (other_seed, other_shas) = make_seed_repo(1, "alt");
let unrelated_sha = &other_shas[0];
drop(other_seed);
let store = Arc::new(MockStore::new());
store.insert(
format!("repo/refs/heads/main/{unrelated_sha}.bundle"),
Bytes::from_static(b"x"),
);
store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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 produce a refusal");
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:?}",
);
assert!(store.contains(&format!("repo/refs/heads/main/{unrelated_sha}.bundle")));
let local_sha = &shas[0];
assert!(
!store.contains(&format!("repo/refs/heads/main/{local_sha}.bundle")),
"refused push must not upload the local-tip bundle",
);
}
#[tokio::test]
async fn multi_bundle_pre_lock_rejects_push() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let (other_seed, other_shas) = make_seed_repo(1, "alt");
let extra_sha = &other_shas[0];
drop(other_seed);
let store = Arc::new(MockStore::new());
let primary_key = format!("repo/refs/heads/main/{}.bundle", &shas[0]);
let extra_key = format!("repo/refs/heads/main/{extra_sha}.bundle");
store.insert(&primary_key, Bytes::from_static(b"a"));
store.insert(&extra_key, Bytes::from_static(b"b"));
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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 produce a refusal");
let text = std::str::from_utf8(&out).unwrap();
assert_eq!(
text,
"error refs/heads/main \"multiple bundles exist on server. \
Run git-remote-object-store doctor to fix.\"?\n\n",
"got {text:?}",
);
assert!(store.contains(&primary_key));
assert!(store.contains(&extra_key));
}
#[tokio::test]
async fn push_with_held_lock_returns_contention_error() {
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_with_zip(Some("repo"), false),
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 produce a refusal");
let text = std::str::from_utf8(&out).unwrap();
assert!(text.contains("failed to acquire ref lock"), "got {text:?}");
assert!(text.ends_with("\"?\n\n"), "wire envelope dropped: {text:?}");
assert!(store.contains("repo/refs/heads/main/LOCK#.lock"));
}
#[tokio::test]
async fn push_recovers_stale_lock() {
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_with_zip(Some("repo"), false),
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(&format!("repo/refs/heads/main/{}.bundle", &shas[0])));
assert!(!store.contains("repo/refs/heads/main/LOCK#.lock"));
}
#[tokio::test]
async fn zip_variant_uploads_repo_zip_with_metadata() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let sha = &shas[0];
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), true),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("zip push should succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert!(store.contains(&format!("repo/refs/heads/main/{sha}.bundle")));
assert!(store.contains("repo/refs/heads/main/repo.zip"));
let zip_meta = store
.metadata("repo/refs/heads/main/repo.zip")
.expect("zip stored");
let cd = zip_meta
.content_disposition
.expect("Content-Disposition set");
assert!(
cd.starts_with("attachment; filename=repo-")
&& std::path::Path::new(&cd)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip")),
"unexpected CD {cd:?}",
);
let summary = zip_meta
.user_metadata
.iter()
.find(|(k, _)| k == "codepipeline-artifact-revision-summary")
.expect("revision summary metadata");
assert!(!summary.1.is_empty());
}
#[tokio::test]
async fn delete_remote_ref_removes_single_bundle() {
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(
format!("repo/refs/heads/main/{}.bundle", &shas[0]),
Bytes::from_static(b"x"),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push :refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("delete should succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert_bundle_engine_delete_swept_clean(&store, "repo", "refs/heads/main").await;
}
#[tokio::test]
async fn delete_protected_remote_ref_emits_protection_refusal() {
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(
format!("repo/refs/heads/main/{}.bundle", &shas[0]),
Bytes::from_static(b"x"),
);
store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push :refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("delete should produce a refusal");
let text = std::str::from_utf8(&out).unwrap();
assert_eq!(
text,
"error refs/heads/main \"ref is protected. \
Run git-remote-object-store unprotect <url> <branch> \
to remove protection before deleting.\"?\n\n",
);
assert!(store.contains(&format!("repo/refs/heads/main/{}.bundle", &shas[0])));
assert!(store.contains("repo/refs/heads/main/PROTECTED#"));
}
#[tokio::test]
async fn delete_missing_remote_ref_emits_not_found() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push :refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("delete should produce a refusal");
let text = std::str::from_utf8(&out).unwrap();
assert!(text.contains("not found"), "got {text:?}");
assert!(text.ends_with("\"?\n\n"), "wire envelope dropped: {text:?}");
}
#[tokio::test]
async fn delete_with_held_lock_returns_contention_error() {
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(
format!("repo/refs/heads/main/{}.bundle", &shas[0]),
Bytes::from_static(b"x"),
);
store.insert_with(
"repo/refs/heads/main/LOCK#.lock",
Bytes::new(),
OffsetDateTime::now_utc(),
PutOpts::default(),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push :refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("delete should produce a refusal, not a hard error");
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 \
or deleting. 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(&format!("repo/refs/heads/main/{}.bundle", &shas[0])),
"bundle must survive the lock-contention refusal",
);
assert!(store.contains("repo/refs/heads/main/LOCK#.lock"));
}
#[tokio::test]
async fn delete_acquires_and_releases_per_ref_lock() {
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(
format!("repo/refs/heads/main/{}.bundle", &shas[0]),
Bytes::from_static(b"x"),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push :refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("delete should succeed");
let text = std::str::from_utf8(&out).unwrap();
assert_eq!(text, "ok refs/heads/main\n\n");
assert_bundle_engine_delete_swept_clean(&store, "repo", "refs/heads/main").await;
}
#[tokio::test]
async fn batched_pushes_emit_outcome_per_command() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(1, "primary");
let sha = &shas[0];
git(
&["update-ref", "refs/heads/feature", "refs/heads/main"],
seed.path(),
);
let store = Arc::new(MockStore::new());
let script = "push refs/heads/main:refs/heads/main\n\
push refs/heads/feature:refs/heads/feature\n\n";
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
script,
seed.path().to_path_buf(),
)
.await;
result.expect("batched push should succeed");
let text = std::str::from_utf8(&out).unwrap();
assert_eq!(
text, "ok refs/heads/main\nok refs/heads/feature\n\n",
"two ok lines + single trailing terminator",
);
assert!(store.contains(&format!("repo/refs/heads/main/{sha}.bundle")));
assert!(store.contains(&format!("repo/refs/heads/feature/{sha}.bundle")));
}
#[tokio::test]
async fn nonexistent_local_ref_emits_error() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, _) = make_seed_repo(1, "primary");
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/heads/does-not-exist:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("missing local ref should produce a refusal, not abort");
let text = std::str::from_utf8(&out).unwrap();
assert!(text.contains("not found"), "got {text:?}");
assert!(text.ends_with("\"?\n\n"), "wire envelope dropped: {text:?}");
}
#[tokio::test]
async fn force_push_protected_with_ancestor_remote_proceeds() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, shas) = make_seed_repo(2, "primary");
let ancestor = &shas[0];
let descendant = &shas[1];
let store = Arc::new(MockStore::new());
store.insert(
format!("repo/refs/heads/main/{ancestor}.bundle"),
Bytes::from_static(b"old"),
);
store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push +refs/heads/main:refs/heads/main\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("force-protected fast-forward should succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/heads/main\n\n");
assert!(store.contains(&format!("repo/refs/heads/main/{descendant}.bundle")));
assert!(
store.contains(&format!("repo/refs/heads/main/{ancestor}.bundle")),
"old bundle must remain readable during the grace window",
);
let metas = store.list("repo/gc/").await.unwrap();
let tombstones: Vec<_> = metas
.iter()
.filter(|m| m.key.starts_with("repo/gc/baseline-tomb-"))
.collect();
assert_eq!(
tombstones.len(),
1,
"exactly one baseline tombstone must be written; got {:?}",
tombstones.iter().map(|m| &m.key).collect::<Vec<_>>(),
);
let body = store.get_bytes(&tombstones[0].key).await.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["ref_name"].as_str(), Some("refs/heads/main"));
assert_eq!(
parsed["sha"].as_str(),
Some(ancestor.as_str()),
"tombstone must name the prior (ancestor) bundle's SHA",
);
assert!(store.contains("repo/refs/heads/main/PROTECTED#"));
}
#[tokio::test]
async fn batched_push_continues_after_per_push_transport_failure() {
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(1, "primary");
let sha = &shas[0];
git(
&["update-ref", "refs/heads/feature", "refs/heads/main"],
seed.path(),
);
let store = Arc::new(MockStore::new());
store.arm(Fault::AccessDeniedOnList {
prefix: "repo/refs/heads/feature/".into(),
});
let script = "push refs/heads/main:refs/heads/main\n\
push refs/heads/feature:refs/heads/feature\n\n";
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
script,
seed.path().to_path_buf(),
)
.await;
result.expect("batch with one failing push should not abort the helper");
let text = std::str::from_utf8(&out).unwrap();
let mut lines = text.split_inclusive('\n');
assert_eq!(lines.next(), Some("ok refs/heads/main\n"));
let second = lines.next().expect("error line for push #2");
assert!(
second.starts_with("error refs/heads/feature "),
"expected error for second push, got {second:?}",
);
assert!(
second.contains("access denied"),
"error message must surface the underlying failure: {second:?}",
);
assert_eq!(lines.next(), Some("\n"), "trailing batch terminator");
assert!(lines.next().is_none(), "no extra output: {text:?}");
assert!(store.contains(&format!("repo/refs/heads/main/{sha}.bundle")));
assert!(!store.contains(&format!("repo/refs/heads/feature/{sha}.bundle")));
assert_eq!(store.pending_faults(), 0);
}
#[tokio::test]
async fn lock_release_failure_overrides_successful_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(1, "primary");
let sha = &shas[0];
let store = Arc::new(MockStore::new());
let lock_key = "repo/refs/heads/main/LOCK#.lock";
store.arm(Fault::NetworkOnDelete {
key: lock_key.into(),
});
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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 produce an error outcome, not abort");
let text = std::str::from_utf8(&out).unwrap();
assert!(
text.starts_with("error refs/heads/main "),
"expected error line, got {text:?}",
);
assert!(
text.contains("failed to release lock"),
"error message must mention lock release failure: {text:?}",
);
assert!(
text.contains("doctor"),
"error message must point user at doctor: {text:?}",
);
assert!(store.contains(&format!("repo/refs/heads/main/{sha}.bundle")));
assert!(store.contains(lock_key));
assert!(store.contains("repo/HEAD"));
assert_eq!(store.pending_faults(), 0);
}
#[tokio::test]
async fn pre_lock_multi_bundle_rejection_surfaces_unchanged() {
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 sha_a = "1111111111111111111111111111111111111111";
let sha_b = "2222222222222222222222222222222222222222";
store.insert(
format!("repo/refs/heads/main/{sha_a}.bundle"),
Bytes::from_static(b"a"),
);
store.insert(
format!("repo/refs/heads/main/{sha_b}.bundle"),
Bytes::from_static(b"b"),
);
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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 produce an error outcome, not abort");
let text = std::str::from_utf8(&out).unwrap();
assert_eq!(
text,
"error refs/heads/main \"multiple bundles exist on server. \
Run git-remote-object-store doctor to fix.\"?\n\n",
"got {text:?}",
);
assert!(
!store.contains("repo/refs/heads/main/LOCK#.lock"),
"lock must not be acquired when the pre-lock check rejects",
);
}
#[tokio::test]
async fn pre_existing_malformed_bundle_key_is_ignored_by_push() {
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 bad_key = "repo/refs/heads/main/not-a-valid-sha.bundle";
store.insert(bad_key, Bytes::from_static(b"junk"));
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
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; malformed key is filtered, not parsed");
let text = std::str::from_utf8(&out).expect("stdout utf-8");
assert_eq!(text, "ok refs/heads/main\n\n");
assert!(
store.contains(bad_key),
"malformed bundle must remain untouched (doctor's job to clean up)",
);
}
#[tokio::test]
async fn bundle_first_push_of_annotated_tag_lands_bundle_at_tag_sha() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (seed, commit_sha, tag_sha) = make_seed_repo_with_annotated_tag("primary", "v1");
assert_ne!(commit_sha, tag_sha, "fixture must produce distinct OIDs");
let store = Arc::new(MockStore::new());
let (out, result) = drive_in(
s3_url_with_zip(Some("repo"), false),
Arc::clone(&store) as Arc<dyn ObjectStore>,
"push refs/tags/v1:refs/tags/v1\n\n",
seed.path().to_path_buf(),
)
.await;
result.expect("bundle annotated-tag push must succeed");
assert_eq!(std::str::from_utf8(&out).unwrap(), "ok refs/tags/v1\n\n");
let tag_key = format!("repo/refs/tags/v1/{tag_sha}.bundle");
assert!(
store.contains(&tag_key),
"bundle must land at {tag_key} (named after the tag OID)",
);
let commit_key = format!("repo/refs/tags/v1/{commit_sha}.bundle");
assert!(
!store.contains(&commit_key),
"bundle must NOT be named after the commit (would mean we peeled before naming)",
);
}