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