a73x

tests/issue_reopen_test.rs

Ref:   Size: 10.7 KiB   History

//! Issue-side close/reopen convergence across two clones.
//!
//! `close` moves an object's ref between the active and the archive namespace
//! while its id stays the same, so one clone that closed and another that
//! reopened publish the *same* object under two different sync prefixes. Sync
//! has to recognise those as one object and reconcile them into one DAG;
//! reconciling only the active prefix adopts them as two divergent histories
//! for one id, which no later sync can reunite. See `64194908` and
//! `state::existing_events_ref`.
//!
//! `tests/patch_reopen_test.rs` covers the patch side of the same code path.
//! This is its issue-side twin — issues are the more common object and
//! `issue reopen` is much older than `patch reopen`, so this is the side more
//! likely to have been hit in practice.
//!
//! Unlike the patch-side test, both cases below pin *which* side wins rather
//! than only asserting the two clones agree. The fold is ordered by
//! `(clock, oid)` and a clock is `max_clock(tip) + 1`, so giving the winning
//! clone one extra local event before its status event puts its status event a
//! clock ahead — no tie, no dependence on how two oids happen to sort.

mod common;

use std::process::Command;

use serde_json::Value;
use tempfile::TempDir;

use common::TestRepo;

// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------

fn repo_with_origin() -> (TestRepo, TempDir) {
    let bare = TempDir::new().unwrap();
    let status = Command::new("git")
        .args(["init", "--bare", "-b", "main"])
        .arg(bare.path())
        .status()
        .unwrap();
    assert!(status.success());

    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
    repo.git(&["push", "-u", "origin", "main"]);
    repo.run_ok(&["init"]);
    (repo, bare)
}

fn git_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String {
    let mut command = Command::new("git");
    env_from.apply_env(&mut command);
    let output = command.args(args).current_dir(dir).output().unwrap();
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap()
}

fn collab_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String {
    let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab"));
    env_from.apply_env(&mut command);
    let output = command.args(args).current_dir(dir).output().unwrap();
    assert!(
        output.status.success(),
        "git-collab {:?} failed:\nstdout: {}\nstderr: {}",
        args,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap()
}

/// A second clone of `bare`, configured as Bob and initialised for collab.
/// Returns the tempdir keeping it alive and the path to the clone itself.
fn second_clone(alice: &TestRepo, bare: &TempDir) -> (TempDir, std::path::PathBuf) {
    let root = TempDir::new().unwrap();
    let dir = root.path().join("clone");
    git_in(
        alice,
        root.path(),
        &["clone", bare.path().to_str().unwrap(), "clone"],
    );
    git_in(alice, &dir, &["config", "user.name", "Bob"]);
    git_in(alice, &dir, &["config", "user.email", "bob@example.com"]);
    git_in(alice, &dir, &["config", "collab.autoSync", "false"]);
    collab_in(alice, &dir, &["init"]);
    collab_in(alice, &dir, &["sync"]);
    (root, dir)
}

fn show_json(repo: &TestRepo, id: &str) -> Value {
    serde_json::from_str(&repo.run_ok(&["issue", "show", id, "--json"])).unwrap()
}

fn show_json_in(env_from: &TestRepo, dir: &std::path::Path, id: &str) -> Value {
    serde_json::from_str(&collab_in(env_from, dir, &["issue", "show", id, "--json"])).unwrap()
}

/// The events ref for `short` in a `for-each-ref` listing.
///
/// Each clone is asked for its own. Which namespace a clone files the object
/// in is a local decision — only the clone that ran `close` moves the ref, so
/// a close that merely *arrives* by sync leaves it where it was — and neither
/// filing is wrong. The DAG tip and the folded status are the facts that have
/// to match.
fn issue_events_ref_in(listing: &str, short: &str) -> String {
    listing
        .lines()
        .find(|r| r.contains(short))
        .unwrap_or_else(|| panic!("no events ref for {} in {}", short, listing))
        .to_string()
}

fn issue_events_ref(repo: &TestRepo, short: &str) -> String {
    let out = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]);
    issue_events_ref_in(&out, short)
}

fn issue_events_ref_at(env_from: &TestRepo, dir: &std::path::Path, short: &str) -> String {
    let out = git_in(
        env_from,
        dir,
        &["for-each-ref", "--format=%(refname)", "refs/collab/"],
    );
    issue_events_ref_in(&out, short)
}

/// Assert both clones ended on one DAG — the property the whole fix exists for.
/// Divergence is exactly "two tips for one id", so comparing tips is what
/// catches it; the folded status can coincide even when the histories have not
/// been reunited.
fn assert_same_tip(alice: &TestRepo, bob: &std::path::Path, short: &str) {
    let alice_ref = issue_events_ref(alice, short);
    let bob_ref = issue_events_ref_at(alice, bob, short);
    assert_eq!(
        alice.git(&["rev-parse", &alice_ref]).trim(),
        git_in(alice, bob, &["rev-parse", &bob_ref]).trim(),
        "both clones must end at the same DAG tip ({} vs {})",
        alice_ref,
        bob_ref
    );
}

// ---------------------------------------------------------------------------
// Convergence: one clone closes, another reopens, neither having seen the other
// ---------------------------------------------------------------------------

#[test]
fn a_concurrent_close_and_reopen_converge_on_the_later_close() {
    // Alice's close is the later event, so `closed` is the answer both clones
    // owe. Alice is also the clone whose ref moves into the archive namespace,
    // which is the half that a sync globbing only the active prefix never sees.
    let (alice, bare) = repo_with_origin();
    let short = alice.issue_open("A bug");
    alice.run_ok(&["sync"]);

    let (_bob_root, bob) = second_clone(&alice, &bare);

    // Alice takes an extra clock tick before closing, so her close sits at a
    // strictly higher clock than Bob's reopen and the winner is not decided by
    // whichever oid happens to sort higher this run.
    alice.run_ok(&["issue", "comment", &short, "-b", "cannot reproduce"]);
    alice.run_ok(&["issue", "close", &short]);
    collab_in(&alice, &bob, &["issue", "reopen", &short]);

    // Alice publishes first, so Bob's sync is the one that has to reconcile a
    // genuinely divergent DAG arriving under the *other* namespace.
    alice.run_ok(&["sync"]);
    collab_in(&alice, &bob, &["sync"]);
    alice.run_ok(&["sync"]);

    assert_eq!(
        show_json(&alice, &short)["status"],
        "closed",
        "the later close wins on the clone that wrote it"
    );
    assert_eq!(
        show_json_in(&alice, &bob, &short)["status"],
        "closed",
        "and the clone that reopened has to accept it once it has seen it"
    );
    assert_same_tip(&alice, &bob, &short);
}

#[test]
fn a_concurrent_close_and_reopen_converge_on_the_later_reopen() {
    // The mirror image, and the direction that also exercises
    // `unarchive_if_reopened`: Alice filed the issue away under `close`, and
    // the reconciled DAG says it is open again, so it has to come back out of
    // her archive or it is open and unfindable on her clone.
    let (alice, bare) = repo_with_origin();
    let short = alice.issue_open("A bug");
    alice.run_ok(&["sync"]);

    let (_bob_root, bob) = second_clone(&alice, &bare);

    alice.run_ok(&["issue", "close", &short]);
    // Bob's extra tick, so his reopen is the strictly later event.
    collab_in(
        &alice,
        &bob,
        &["issue", "comment", &short, "-b", "still here"],
    );
    collab_in(&alice, &bob, &["issue", "reopen", &short]);

    alice.run_ok(&["sync"]);
    collab_in(&alice, &bob, &["sync"]);
    alice.run_ok(&["sync"]);

    assert_eq!(
        show_json_in(&alice, &bob, &short)["status"],
        "open",
        "the later reopen wins on the clone that wrote it"
    );
    let alice_view = show_json(&alice, &short);
    assert_eq!(
        alice_view["status"], "open",
        "and the clone that closed has to accept it once it has seen it"
    );
    assert!(
        alice_view["close_reason"].is_null(),
        "an open issue must not still carry the close it denies: {}",
        alice_view
    );
    assert_same_tip(&alice, &bob, &short);

    // The user-visible half: nothing enumerates the archive namespace, so an
    // issue reopened elsewhere that stayed filed away would be open and
    // invisible here.
    assert!(
        issue_events_ref(&alice, &short).starts_with("refs/collab/issues/"),
        "a reopen arriving by sync has to bring the issue back out of the archive, got {}",
        issue_events_ref(&alice, &short)
    );
    let listed = alice.run_ok(&["issue", "list"]);
    assert!(
        listed.contains(&short),
        "and back into the default list: {}",
        listed
    );
}

#[test]
fn both_clones_keep_every_concurrent_event() {
    // Convergence on one tip is not enough if the merge dropped events on the
    // way: the comment each clone wrote while offline has to survive on both,
    // which is what proves the two histories were reunited rather than one of
    // them being adopted wholesale.
    let (alice, bare) = repo_with_origin();
    let short = alice.issue_open("A bug");
    alice.run_ok(&["sync"]);

    let (_bob_root, bob) = second_clone(&alice, &bare);

    alice.run_ok(&["issue", "comment", &short, "-b", "from alice"]);
    alice.run_ok(&["issue", "close", &short]);
    collab_in(
        &alice,
        &bob,
        &["issue", "comment", &short, "-b", "from bob"],
    );
    collab_in(&alice, &bob, &["issue", "reopen", &short]);

    alice.run_ok(&["sync"]);
    collab_in(&alice, &bob, &["sync"]);
    alice.run_ok(&["sync"]);

    for view in [
        show_json(&alice, &short),
        show_json_in(&alice, &bob, &short),
    ] {
        let bodies: Vec<String> = view["comments"]
            .as_array()
            .unwrap_or_else(|| panic!("no comments array: {}", view))
            .iter()
            .map(|c| c["body"].as_str().unwrap_or("").to_string())
            .collect();
        assert!(
            bodies.iter().any(|b| b == "from alice") && bodies.iter().any(|b| b == "from bob"),
            "both clones' offline comments must survive the merge, got {:?}",
            bodies
        );
    }
}