a73x

tests/patch_reopen_test.rs

Ref:   Size: 13.1 KiB   History

//! `patch reopen`: the other half of the merge-recording escape hatch.
//!
//! `issue reopen` has always existed; `patch reopen` did not, so a patch closed
//! by mistake stayed closed forever, and a `PatchMerge` recorded in error could
//! only be corrected into `closed` — never back to `open`. See issue 259200d4
//! and docs/superpowers/specs/2026-08-09-merge-recording-design.md.

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

/// Create a patch on its own branch and return its short id.
fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> String {
    repo.git(&["checkout", "-b", branch]);
    repo.commit_file(file, "content", &format!("work on {}", branch));
    let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    short
}

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

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

/// The events ref for `short` in a `for-each-ref` listing. Each clone is asked
/// for its own: a closed object lives in the archive namespace on the clone
/// that closed it and in the active one on a clone that only learned of the
/// close by sync, and neither is wrong — the namespace is a filing decision,
/// the DAG tip and the status are the facts that have to match.
fn events_ref_in(listing: &str, short: &str) -> String {
    listing
        .lines()
        .find(|r| r.contains(short) && r.ends_with("/events"))
        .unwrap_or_else(|| panic!("no events ref for {} in {}", short, listing))
        .to_string()
}

// ---------------------------------------------------------------------------
// The command exists and undoes a close
// ---------------------------------------------------------------------------

#[test]
fn patch_reopen_returns_a_closed_patch_to_open() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_on_branch(&repo, "feat", "a.txt");

    repo.run_ok(&["patch", "close", &short]);
    assert_eq!(show_json(&repo, &short)["status"], "closed");

    repo.run_ok(&["patch", "reopen", &short]);
    assert_eq!(
        show_json(&repo, &short)["status"],
        "open",
        "reopen is the missing half of close; a patch closed by mistake has to come back"
    );
}

#[test]
fn a_reopened_patch_is_listed_again_without_all() {
    // `close` archives the whole subtree, so a reopen that only appended an
    // event would leave the patch open and invisible.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_on_branch(&repo, "feat", "a.txt");

    repo.run_ok(&["patch", "close", &short]);
    let listed = repo.run_ok(&["patch", "list"]);
    assert!(
        !listed.contains(&short),
        "a closed patch is not in the default list: {}",
        listed
    );

    repo.run_ok(&["patch", "reopen", &short]);
    let listed = repo.run_ok(&["patch", "list"]);
    assert!(
        listed.contains(&short),
        "a reopened patch belongs back in the default list: {}",
        listed
    );

    let events_ref = patch_events_ref(&repo, &short);
    assert!(
        events_ref.starts_with("refs/collab/patches/"),
        "the subtree moves back out of the archive namespace, got {}",
        events_ref
    );
}

#[test]
fn reopening_a_patch_keeps_its_revisions_reachable() {
    // Archiving moves `<id>/r/<n>` along with `<id>/events`; unarchiving has to
    // bring them back, or the reopened patch has no revisions to review.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_on_branch(&repo, "feat", "a.txt");

    repo.run_ok(&["patch", "close", &short]);
    repo.run_ok(&["patch", "reopen", &short]);

    let refs = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]);
    let revision_refs: Vec<&str> = refs
        .lines()
        .filter(|r| r.contains(short.as_str()) && r.contains("/rev/"))
        .collect();
    assert!(
        !revision_refs.is_empty(),
        "the revision refs must come back with the patch: {}",
        refs
    );
    assert!(
        revision_refs
            .iter()
            .all(|r| r.starts_with("refs/collab/patches/")),
        "no revision ref may be left in the archive: {:?}",
        revision_refs
    );
    assert_eq!(show_json(&repo, &short)["revisions"][0]["number"], 1);
}

// ---------------------------------------------------------------------------
// Reopening a merged patch
// ---------------------------------------------------------------------------

#[test]
fn reopening_a_merged_patch_clears_the_merge_commit() {
    // `merge_commit` is written under the same `(clock, oid)` guard as `status`
    // precisely so the two can never disagree. `PatchClose` already clears it;
    // an *open* patch still naming the commit that landed it would be the same
    // contradiction, and reopen is the documented correction for a `PatchMerge`
    // recorded in error — so it has to undo all of what the merge recorded.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_on_branch(&repo, "feat", "a.txt");
    repo.git(&["merge", "--ff-only", "feat"]);
    repo.run_ok(&["patch", "merge", &short]);

    let merged = show_json(&repo, &short);
    assert_eq!(merged["status"], "merged");
    assert!(merged["merge_commit"].is_string(), "{}", merged);

    repo.run_ok(&["patch", "reopen", &short]);
    let reopened = show_json(&repo, &short);
    assert_eq!(reopened["status"], "open");
    assert!(
        reopened["merge_commit"].is_null(),
        "an open patch must not name a merge the status denies: {}",
        reopened
    );

    let shown = repo.run_ok(&["patch", "show", &short]);
    assert!(
        !shown.contains("Merged in:"),
        "the text view must agree with the state: {}",
        shown
    );
}

#[test]
fn a_merge_recorded_after_a_reopen_wins_again() {
    // Reopen is not a terminal state: recording the merge again re-records it,
    // which is what makes "reopened by mistake" recoverable in turn.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_on_branch(&repo, "feat", "a.txt");
    repo.git(&["merge", "--ff-only", "feat"]);
    repo.run_ok(&["patch", "merge", &short]);
    repo.run_ok(&["patch", "reopen", &short]);
    repo.run_ok(&["patch", "merge", &short]);

    let json = show_json(&repo, &short);
    assert_eq!(json["status"], "merged");
    assert!(json["merge_commit"].is_string(), "{}", json);
}

// ---------------------------------------------------------------------------
// The event, and how it folds
// ---------------------------------------------------------------------------

#[test]
fn patch_reopen_event_serializes_under_its_own_type() {
    use git_collab::event::{Action, Author, Event};

    let event = Event {
        timestamp: "2026-08-11T12:00:00Z".to_string(),
        author: Author {
            name: "Alice".to_string(),
            email: "alice@example.com".to_string(),
        },
        action: Action::PatchReopen,
        clock: 7,
    };

    let json = serde_json::to_string(&event).unwrap();
    assert!(json.contains("\"type\":\"patch.reopen\""), "{}", json);
    let round_tripped: Event = serde_json::from_str(&json).unwrap();
    assert!(matches!(round_tripped.action, Action::PatchReopen));
}

#[test]
fn a_close_recorded_after_a_reopen_still_wins() {
    // The fold is ordered by `(clock, oid)`, not by variant. A later close has
    // to beat an earlier reopen exactly as a later reopen beats a close.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_on_branch(&repo, "feat", "a.txt");

    repo.run_ok(&["patch", "close", &short]);
    repo.run_ok(&["patch", "reopen", &short]);
    repo.run_ok(&["patch", "close", &short]);

    assert_eq!(show_json(&repo, &short)["status"], "closed");
}

// ---------------------------------------------------------------------------
// Convergence: one clone closes, another reopens, offline
// ---------------------------------------------------------------------------

#[test]
fn a_concurrent_close_and_reopen_converge() {
    let (alice, bare) = repo_with_origin();
    let short = patch_on_branch(&alice, "feat", "a.txt");
    alice.run_ok(&["sync"]);

    let bob_root = TempDir::new().unwrap();
    let bob = bob_root.path().join("clone");
    git_in(
        &alice,
        bob_root.path(),
        &["clone", bare.path().to_str().unwrap(), "clone"],
    );
    git_in(&alice, &bob, &["config", "user.name", "Bob"]);
    git_in(&alice, &bob, &["config", "user.email", "bob@example.com"]);
    git_in(&alice, &bob, &["config", "collab.autoSync", "false"]);
    collab_in(&alice, &bob, &["init"]);
    collab_in(&alice, &bob, &["sync"]);

    // Neither has seen the other's event when they write theirs.
    alice.run_ok(&["patch", "close", &short]);
    collab_in(&alice, &bob, &["patch", "reopen", &short]);

    // Alice pushes first, so Bob's sync reconciles a genuinely divergent DAG.
    alice.run_ok(&["sync"]);
    collab_in(&alice, &bob, &["sync"]);
    alice.run_ok(&["sync"]);

    let alice_status = show_json(&alice, &short)["status"].clone();
    let bob_json: Value = serde_json::from_str(&collab_in(
        &alice,
        &bob,
        &["patch", "show", &short, "--json"],
    ))
    .unwrap();
    assert_eq!(
        alice_status, bob_json["status"],
        "a close and a reopen that never saw each other must still land on one answer"
    );

    let alice_ref = patch_events_ref(&alice, &short);
    let bob_ref = events_ref_in(
        &git_in(
            &alice,
            &bob,
            &["for-each-ref", "--format=%(refname)", "refs/collab/"],
        ),
        &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"
    );
}

#[test]
fn a_reopen_arriving_by_sync_is_listable_again() {
    // The clone that closed the patch filed it in the archive namespace, where
    // nothing enumerates it. A reopen arriving from a peer has to bring it back
    // out, or the patch is open and unfindable on that clone.
    let (alice, bare) = repo_with_origin();
    let short = patch_on_branch(&alice, "feat", "a.txt");
    alice.run_ok(&["sync"]);

    let bob_root = TempDir::new().unwrap();
    let bob = bob_root.path().join("clone");
    git_in(
        &alice,
        bob_root.path(),
        &["clone", bare.path().to_str().unwrap(), "clone"],
    );
    git_in(&alice, &bob, &["config", "user.name", "Bob"]);
    git_in(&alice, &bob, &["config", "user.email", "bob@example.com"]);
    git_in(&alice, &bob, &["config", "collab.autoSync", "false"]);
    collab_in(&alice, &bob, &["init"]);
    collab_in(&alice, &bob, &["sync"]);

    // Alice closes and publishes; Bob sees the close, then reopens.
    alice.run_ok(&["patch", "close", &short]);
    alice.run_ok(&["sync"]);
    collab_in(&alice, &bob, &["sync"]);
    collab_in(&alice, &bob, &["patch", "reopen", &short]);
    collab_in(&alice, &bob, &["sync"]);
    alice.run_ok(&["sync"]);

    assert_eq!(show_json(&alice, &short)["status"], "open");
    let listed = alice.run_ok(&["patch", "list"]);
    assert!(
        listed.contains(&short),
        "a patch reopened elsewhere has to come back out of this clone's archive: {}",
        listed
    );
}