crates/gunnar-cli/tests/repush.rs
#![cfg(feature = "server")]
//! **Pushing objects the server already holds must work.**
//!
//! Not an edge case. It happens whenever:
//!
//! * a push is refused by a policy gate and the client tries again — the
//! objects landed the first time, because `receive-pack` stores before it
//! gates (`tests/push_facts.rs` asserts that on purpose);
//! * two branches carry the same commits;
//! * a client re-pushes after a network failure;
//! * a mirror push is repeated.
//!
//! Found by `a_push_option_the_policy_demands_is_seen_by_the_gate_over_real_ssh`,
//! which refuses a push and then re-sends it with the option the gate wants.
//! The second push failed with *"the repository is unavailable; try again"* —
//! the redacted sentence for a storage error, which is what made it look like
//! the concurrency defect recorded as *"the repository is unavailable under
//! mixed load"*. It is not about concurrency at all: one client, one connection,
//! the same bytes twice.
mod common;
use common::Harness;
use gunnar_core::HashKind;
fn tools_present() -> bool {
["git", "ssh"].iter().all(|tool| {
std::process::Command::new(tool)
.arg("--version")
.output()
.is_ok()
})
}
/// **The same pack, twice, with no reference pointing at it in between.**
///
/// The reference is deleted between the two pushes, and that is the whole
/// setup: git sends only what the remote's advertisement says it lacks, so a
/// second push to a server that still advertises the branch sends an *empty*
/// pack and proves nothing. With the branch gone, the server advertises
/// nothing, the client re-sends the whole pack — and the objects in it are
/// already in the store.
///
/// That is exactly the state a gate refusal leaves behind: objects stored, no
/// reference moved. Which is why
/// `a_push_option_the_policy_demands_is_seen_by_the_gate_over_real_ssh` hit it
/// and an ordinary re-push does not.
#[test]
fn pushing_a_pack_the_server_already_holds_is_served() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "twice", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "twice");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), b"one\n").expect("write a file");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "-m", "first"])
.expect_ok("git commit");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("the first push");
// Now the objects are stored and nothing names them.
harness
.git(&work, &["push", "--quiet", &url, ":refs/heads/main"])
.expect_ok("delete the branch on the server");
let again = harness.git(&work, &["push", &url, "main"]);
again.expect_ok(
"the second push of objects the server already holds. A store that refuses this refuses \
every retry after a gate refusal, every re-push after a dropped connection, and every \
restore of a branch that was deleted",
);
let remote = harness.git(&work, &["ls-remote", &url, "refs/heads/main"]);
remote.expect_ok("git ls-remote");
assert!(
!remote.stdout.trim().is_empty(),
"the second push reported success and the reference is not there"
);
harness.stop();
}