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