9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
556 lines
19 KiB
Rust
556 lines
19 KiB
Rust
//! End-to-end git-over-object-storage tests.
|
|
//!
|
|
//! Drives the real `git` binary (clone / push / fetch / force-push / tag,
|
|
//! plus a best-effort concurrent push race) against a running relay backed by
|
|
//! S3/MinIO, exercising the full manifest-pointer CAS commit path described in
|
|
//! `docs/git-on-object-storage.md`.
|
|
//!
|
|
//! Requires: relay at localhost:3000 with git + S3/MinIO configured, `git` on
|
|
//! PATH, and the `git-credential-nostr` helper built. All tests are `#[ignore]`
|
|
//! so they don't run in CI by default.
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! ```text
|
|
//! cargo build --release -p git-credential-nostr
|
|
//! GIT_CREDENTIAL_NOSTR_BIN=$PWD/target/release/git-credential-nostr \
|
|
//! cargo test -p buzz-test-client --test e2e_git -- --ignored --nocapture
|
|
//! ```
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
|
|
use buzz_media::S3AddressingStyle;
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use s3::creds::Credentials;
|
|
use s3::{Bucket, Region};
|
|
|
|
fn relay_http_url() -> String {
|
|
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
|
}
|
|
|
|
/// Path to the compiled credential helper. Defaults to the workspace release
|
|
/// build; override with `GIT_CREDENTIAL_NOSTR_BIN`.
|
|
fn credential_helper() -> PathBuf {
|
|
if let Ok(p) = std::env::var("GIT_CREDENTIAL_NOSTR_BIN") {
|
|
return PathBuf::from(p);
|
|
}
|
|
// tests run from the crate dir; the workspace target is two levels up.
|
|
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
p.pop();
|
|
p.pop();
|
|
p.push("target/release/git-credential-nostr");
|
|
p
|
|
}
|
|
|
|
/// Submit a signed event to the relay's HTTP bridge (`POST /events`).
|
|
async fn post_event(event: &nostr::Event) {
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.post(format!("{}/events", relay_http_url()))
|
|
.header("X-Pubkey", event.pubkey.to_hex())
|
|
.header("Content-Type", "application/json")
|
|
.body(serde_json::to_string(event).unwrap())
|
|
.send()
|
|
.await
|
|
.expect("post event");
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"event rejected: {}",
|
|
resp.text().await.unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
/// Create a channel (kind:9007) owned by `keys` and return its UUID.
|
|
///
|
|
/// The git read gate (SEC-005) authorizes against membership in the channel
|
|
/// named by the announcement's `buzz-channel` tag, so every repo these tests
|
|
/// announce must be bound to a channel its owner belongs to — creating the
|
|
/// channel makes the creator its owner-member.
|
|
async fn create_test_channel(keys: &Keys) -> String {
|
|
let channel_uuid = uuid::Uuid::new_v4().to_string();
|
|
let event = EventBuilder::new(Kind::Custom(9007), "")
|
|
.tags(vec![
|
|
Tag::parse(["h", &channel_uuid]).unwrap(),
|
|
Tag::parse(["name", &format!("git-e2e-{channel_uuid}")]).unwrap(),
|
|
Tag::parse(["channel_type", "stream"]).unwrap(),
|
|
Tag::parse(["visibility", "open"]).unwrap(),
|
|
])
|
|
.sign_with_keys(keys)
|
|
.unwrap();
|
|
post_event(&event).await;
|
|
channel_uuid
|
|
}
|
|
|
|
/// Run `git` with the Buzz credential helper and isolated config.
|
|
fn git_status(args: &[&str], cwd: &Path, owner_nsec: &str) -> std::process::Output {
|
|
let helper = credential_helper();
|
|
Command::new("git")
|
|
.args([
|
|
"-c",
|
|
"credential.useHttpPath=true",
|
|
"-c",
|
|
&format!("credential.helper={}", helper.display()),
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"-c",
|
|
"tag.gpgsign=false",
|
|
"-c",
|
|
"user.name=E2E",
|
|
"-c",
|
|
"user.email=e2e@example.com",
|
|
])
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
// Isolate from any machine/agent git config (signing, etc.).
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.env_remove("GIT_CONFIG_COUNT")
|
|
.env("NOSTR_PRIVATE_KEY", owner_nsec)
|
|
.output()
|
|
.expect("spawn git")
|
|
}
|
|
|
|
/// Run `git` with the Buzz credential helper and isolated config. Asserts the
|
|
/// command succeeds; returns stdout.
|
|
fn git(args: &[&str], cwd: &Path, owner_nsec: &str) -> String {
|
|
let out = git_status(args, cwd, owner_nsec);
|
|
assert!(
|
|
out.status.success(),
|
|
"git {:?} failed:\nstdout: {}\nstderr: {}",
|
|
args,
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
String::from_utf8_lossy(&out.stdout).into_owned()
|
|
}
|
|
|
|
struct GitS3Probe {
|
|
bucket: Box<Bucket>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct PointerSnapshot {
|
|
etag: String,
|
|
digest: String,
|
|
}
|
|
|
|
impl GitS3Probe {
|
|
fn bucket(
|
|
endpoint: String,
|
|
access_key: &str,
|
|
secret_key: &str,
|
|
bucket_name: &str,
|
|
region_name: String,
|
|
addressing_style: S3AddressingStyle,
|
|
) -> Box<Bucket> {
|
|
let region = Region::Custom {
|
|
region: region_name,
|
|
endpoint,
|
|
};
|
|
let creds = Credentials::new(Some(access_key), Some(secret_key), None, None, None)
|
|
.expect("S3 credentials");
|
|
let bucket = Bucket::new(bucket_name, region, creds).expect("S3 bucket");
|
|
match addressing_style {
|
|
S3AddressingStyle::Path => bucket.with_path_style(),
|
|
S3AddressingStyle::Virtual => bucket,
|
|
}
|
|
}
|
|
|
|
fn from_env() -> Self {
|
|
// These E2E assertions inspect the relay's backing bucket directly, so
|
|
// they must receive the same provider connection and URL style as the
|
|
// relay. Unit/live MinIO probes in buzz-relay keep explicit local
|
|
// fixtures and do not need provider overrides.
|
|
let endpoint = std::env::var("BUZZ_S3_ENDPOINT")
|
|
.unwrap_or_else(|_| "http://localhost:9000".to_string());
|
|
let access_key =
|
|
std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_string());
|
|
let secret_key =
|
|
std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_string());
|
|
let bucket_name =
|
|
std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string());
|
|
let region_name =
|
|
std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
|
let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE")
|
|
.unwrap_or_else(|_| "path".to_string())
|
|
.parse::<S3AddressingStyle>()
|
|
.expect("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'");
|
|
|
|
let bucket = Self::bucket(
|
|
endpoint,
|
|
&access_key,
|
|
&secret_key,
|
|
&bucket_name,
|
|
region_name,
|
|
addressing_style,
|
|
);
|
|
Self { bucket }
|
|
}
|
|
|
|
fn pointer_key(owner: &str, repo: &str) -> String {
|
|
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
|
if let Ok(community) = std::env::var("BUZZ_E2E_GIT_COMMUNITY_ID") {
|
|
return format!("repos/{community}/{owner}/{repo}/pointer");
|
|
}
|
|
format!("repos/{owner}/{repo}/pointer")
|
|
}
|
|
|
|
async fn pointer(&self, owner: &str, repo: &str) -> Option<PointerSnapshot> {
|
|
let key = Self::pointer_key(owner, repo);
|
|
match self.bucket.get_object(&key).await {
|
|
Ok(resp) => {
|
|
let etag = resp
|
|
.headers()
|
|
.iter()
|
|
.find(|(k, _)| k.eq_ignore_ascii_case("etag"))
|
|
.map(|(_, v)| v.to_string())
|
|
.expect("pointer GET must include ETag");
|
|
let digest = String::from_utf8(resp.to_vec()).expect("pointer body utf-8");
|
|
assert_eq!(digest.len(), 64, "pointer body is manifest digest");
|
|
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
|
|
Some(PointerSnapshot { etag, digest })
|
|
}
|
|
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => None,
|
|
Err(e) => panic!("GET S3 pointer {key} failed: {e}"),
|
|
}
|
|
}
|
|
|
|
async fn require_pointer(&self, owner: &str, repo: &str) -> PointerSnapshot {
|
|
for _ in 0..40 {
|
|
if let Some(p) = self.pointer(owner, repo).await {
|
|
self.assert_manifest_exists(&p.digest).await;
|
|
return p;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
panic!(
|
|
"S3 manifest pointer {} never appeared; git may have fallen back to disk",
|
|
Self::pointer_key(owner, repo)
|
|
);
|
|
}
|
|
|
|
async fn assert_manifest_exists(&self, digest: &str) {
|
|
let key = format!("manifests/{digest}");
|
|
match self.bucket.get_object(&key).await {
|
|
Ok(_) => {}
|
|
Err(e) => panic!("pointer named manifest {key}, but GET failed: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn git_s3_probe_builds_both_addressing_styles() {
|
|
let path = GitS3Probe::bucket(
|
|
"https://storage.example".to_string(),
|
|
"access",
|
|
"secret",
|
|
"buzz-media",
|
|
"us-east-1".to_string(),
|
|
S3AddressingStyle::Path,
|
|
);
|
|
assert!(path.is_path_style());
|
|
assert_eq!(path.url(), "https://storage.example/buzz-media");
|
|
|
|
let virtual_hosted = GitS3Probe::bucket(
|
|
"https://storage.example".to_string(),
|
|
"access",
|
|
"secret",
|
|
"buzz-media",
|
|
"auto".to_string(),
|
|
S3AddressingStyle::Virtual,
|
|
);
|
|
assert!(virtual_hosted.is_subdomain_style());
|
|
assert_eq!(virtual_hosted.url(), "https://buzz-media.storage.example");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires live relay + MinIO + git"]
|
|
async fn git_clone_push_fetch_force_roundtrip() {
|
|
use nostr::ToBech32;
|
|
|
|
let owner = Keys::generate();
|
|
let owner_hex = owner.public_key().to_hex();
|
|
let owner_nsec = owner.secret_key().to_bech32().unwrap();
|
|
let repo = format!("e2e-git-{}", std::process::id());
|
|
let s3 = GitS3Probe::from_env();
|
|
|
|
// Announce the repo (kind:30617) so the relay creates the bare repo + hook.
|
|
// The `buzz-channel` binding is the repo's ACL: without it the read gate
|
|
// 404s even for the owner (issue #3527), so bind to a channel the owner
|
|
// just created (and therefore belongs to).
|
|
let channel = create_test_channel(&owner).await;
|
|
let announce = EventBuilder::new(Kind::from(30617), "")
|
|
.tags(vec![
|
|
Tag::parse(["d", &repo]).unwrap(),
|
|
Tag::parse(["name", "e2e git repo"]).unwrap(),
|
|
Tag::parse(["buzz-channel", &channel]).unwrap(),
|
|
])
|
|
.sign_with_keys(&owner)
|
|
.unwrap();
|
|
post_event(&announce).await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
let tmp = tempdir();
|
|
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
|
|
|
|
// 1. Clone the empty repo.
|
|
git(
|
|
&["clone", "--quiet", &url, "clone1"],
|
|
tmp.path(),
|
|
&owner_nsec,
|
|
);
|
|
let clone1 = tmp.path().join("clone1");
|
|
assert!(clone1.exists(), "clone1 created");
|
|
let empty_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
|
|
|
// 2. Push an initial commit.
|
|
std::fs::write(clone1.join("README.md"), "hello\n").unwrap();
|
|
git(&["add", "."], &clone1, &owner_nsec);
|
|
git(
|
|
&["commit", "--quiet", "-m", "initial"],
|
|
&clone1,
|
|
&owner_nsec,
|
|
);
|
|
git(&["branch", "-M", "main"], &clone1, &owner_nsec);
|
|
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
|
|
let p1 = s3.require_pointer(&owner_hex, &repo).await;
|
|
assert_ne!(
|
|
p1, empty_pointer,
|
|
"initial push must advance S3 manifest pointer"
|
|
);
|
|
let sha1 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
|
.trim()
|
|
.to_string();
|
|
|
|
// 3. A fresh clone observes the pushed content and exact SHA.
|
|
git(
|
|
&["clone", "--quiet", &url, "clone2"],
|
|
tmp.path(),
|
|
&owner_nsec,
|
|
);
|
|
let clone2 = tmp.path().join("clone2");
|
|
assert_eq!(
|
|
std::fs::read_to_string(clone2.join("README.md")).unwrap(),
|
|
"hello\n",
|
|
"fresh clone sees pushed content"
|
|
);
|
|
assert_eq!(
|
|
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
|
|
sha1,
|
|
"fresh clone main == pushed SHA"
|
|
);
|
|
|
|
// 4. Second commit, push, pull into the other clone.
|
|
std::fs::write(clone1.join("README.md"), "hello\nmore\n").unwrap();
|
|
git(
|
|
&["commit", "--quiet", "-am", "second"],
|
|
&clone1,
|
|
&owner_nsec,
|
|
);
|
|
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
|
|
let p2 = s3.require_pointer(&owner_hex, &repo).await;
|
|
assert_ne!(p2, p1, "second push must advance S3 manifest pointer");
|
|
let sha2 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
|
.trim()
|
|
.to_string();
|
|
git(&["pull", "--quiet", "origin", "main"], &clone2, &owner_nsec);
|
|
assert_eq!(
|
|
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
|
|
sha2,
|
|
"clone2 fetched second commit"
|
|
);
|
|
|
|
// 5. Force-push a rewritten history.
|
|
git(&["reset", "--quiet", "--hard", &sha1], &clone1, &owner_nsec);
|
|
std::fs::write(clone1.join("README.md"), "forced\n").unwrap();
|
|
git(
|
|
&["commit", "--quiet", "-am", "forced"],
|
|
&clone1,
|
|
&owner_nsec,
|
|
);
|
|
let sha_f = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
|
.trim()
|
|
.to_string();
|
|
git(
|
|
&["push", "--quiet", "--force", "origin", "main"],
|
|
&clone1,
|
|
&owner_nsec,
|
|
);
|
|
let p3 = s3.require_pointer(&owner_hex, &repo).await;
|
|
assert_ne!(p3, p2, "force push must advance S3 manifest pointer");
|
|
assert_ne!(sha_f, sha2);
|
|
|
|
// 6. A new clone after the force-push gets the rewritten history.
|
|
git(
|
|
&["clone", "--quiet", &url, "clone3"],
|
|
tmp.path(),
|
|
&owner_nsec,
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(tmp.path().join("clone3/README.md")).unwrap(),
|
|
"forced\n",
|
|
"clone3 has force-pushed content"
|
|
);
|
|
|
|
// 7. Tag push survives the round-trip.
|
|
git(&["tag", "v1.0"], &clone1, &owner_nsec);
|
|
git(&["push", "--quiet", "origin", "v1.0"], &clone1, &owner_nsec);
|
|
let p4 = s3.require_pointer(&owner_hex, &repo).await;
|
|
assert_ne!(p4, p3, "tag push must advance S3 manifest pointer");
|
|
git(
|
|
&["clone", "--quiet", &url, "clone4"],
|
|
tmp.path(),
|
|
&owner_nsec,
|
|
);
|
|
let tags = git(&["tag"], &tmp.path().join("clone4"), &owner_nsec);
|
|
assert!(tags.contains("v1.0"), "tag v1.0 cloned back: {tags}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires live relay + MinIO + git"]
|
|
async fn git_concurrent_push_one_wins_and_repo_recovers() {
|
|
use nostr::ToBech32;
|
|
|
|
let owner = Keys::generate();
|
|
let owner_hex = owner.public_key().to_hex();
|
|
let owner_nsec = owner.secret_key().to_bech32().unwrap();
|
|
let repo = format!("e2e-git-concurrent-{}", std::process::id());
|
|
let s3 = GitS3Probe::from_env();
|
|
|
|
let channel = create_test_channel(&owner).await;
|
|
let announce = EventBuilder::new(Kind::from(30617), "")
|
|
.tags(vec![
|
|
Tag::parse(["d", &repo]).unwrap(),
|
|
Tag::parse(["name", "e2e concurrent git repo"]).unwrap(),
|
|
Tag::parse(["buzz-channel", &channel]).unwrap(),
|
|
])
|
|
.sign_with_keys(&owner)
|
|
.unwrap();
|
|
post_event(&announce).await;
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
let tmp = tempdir_named("buzz-e2e-git-concurrent");
|
|
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
|
|
|
|
git(&["clone", "--quiet", &url, "seed"], tmp.path(), &owner_nsec);
|
|
let seed = tmp.path().join("seed");
|
|
std::fs::write(seed.join("README.md"), "base\n").unwrap();
|
|
git(&["add", "."], &seed, &owner_nsec);
|
|
git(&["commit", "--quiet", "-m", "base"], &seed, &owner_nsec);
|
|
git(&["branch", "-M", "main"], &seed, &owner_nsec);
|
|
git(&["push", "--quiet", "origin", "main"], &seed, &owner_nsec);
|
|
let base_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
|
let base_sha = git(&["rev-parse", "main"], &seed, &owner_nsec)
|
|
.trim()
|
|
.to_string();
|
|
|
|
let contenders = 8usize;
|
|
let mut contenders_info = Vec::new();
|
|
for i in 0..contenders {
|
|
let dir = format!("c{i}");
|
|
git(&["clone", "--quiet", &url, &dir], tmp.path(), &owner_nsec);
|
|
let worktree = tmp.path().join(&dir);
|
|
std::fs::write(
|
|
worktree.join(format!("file-{i}.txt")),
|
|
format!("winner? {i}\n"),
|
|
)
|
|
.unwrap();
|
|
git(&["add", "."], &worktree, &owner_nsec);
|
|
git(
|
|
&["commit", "--quiet", "-m", &format!("contender {i}")],
|
|
&worktree,
|
|
&owner_nsec,
|
|
);
|
|
let sha = git(&["rev-parse", "main"], &worktree, &owner_nsec)
|
|
.trim()
|
|
.to_string();
|
|
contenders_info.push((i, sha));
|
|
}
|
|
|
|
let mut children = Vec::new();
|
|
for i in 0..contenders {
|
|
let worktree = tmp.path().join(format!("c{i}"));
|
|
let owner_nsec = owner_nsec.clone();
|
|
children.push(std::thread::spawn(move || {
|
|
git_status(
|
|
&["push", "--quiet", "origin", "main"],
|
|
&worktree,
|
|
&owner_nsec,
|
|
)
|
|
}));
|
|
}
|
|
|
|
let mut successes = Vec::new();
|
|
let mut failures = 0usize;
|
|
for (i, child) in children.into_iter().enumerate() {
|
|
let out = child.join().expect("push thread panicked");
|
|
if out.status.success() {
|
|
successes.push(i);
|
|
} else {
|
|
failures += 1;
|
|
}
|
|
}
|
|
assert_eq!(successes.len(), 1, "exactly one concurrent push should win");
|
|
assert_eq!(failures, contenders - 1, "the rest should lose cleanly");
|
|
let winner_index = successes[0];
|
|
let winner_sha = contenders_info
|
|
.iter()
|
|
.find_map(|(i, sha)| (*i == winner_index).then_some(sha.clone()))
|
|
.expect("winner sha recorded");
|
|
|
|
let after_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
|
assert_ne!(
|
|
after_pointer, base_pointer,
|
|
"winning push must advance S3 manifest pointer"
|
|
);
|
|
|
|
git(
|
|
&["clone", "--quiet", &url, "after"],
|
|
tmp.path(),
|
|
&owner_nsec,
|
|
);
|
|
let after = tmp.path().join("after");
|
|
let after_sha = git(&["rev-parse", "main"], &after, &owner_nsec)
|
|
.trim()
|
|
.to_string();
|
|
assert_ne!(after_sha, base_sha, "winner advanced main");
|
|
assert_eq!(
|
|
after_sha, winner_sha,
|
|
"published head must equal the successful contender's tip"
|
|
);
|
|
let log = git(
|
|
&["log", "--oneline", "--decorate", "-1"],
|
|
&after,
|
|
&owner_nsec,
|
|
);
|
|
assert!(
|
|
log.contains("contender"),
|
|
"published head is one contender: {log}"
|
|
);
|
|
}
|
|
|
|
struct TempDir(PathBuf);
|
|
impl TempDir {
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
fn tempdir() -> TempDir {
|
|
tempdir_named("buzz-e2e-git")
|
|
}
|
|
|
|
fn tempdir_named(prefix: &str) -> TempDir {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!("{prefix}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&p);
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
TempDir(p)
|
|
}
|