Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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();
}