a73x

tests/merge_recording_test.rs

Ref:   Size: 45.4 KiB   History

//! Merges are recorded as events, not derived on read.
//!
//! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
//!
//! Layer 3 (`patch merge`) and layer 2 (sync scanning `Patch:` trailers) are
//! covered here, along with the demotion of reachability detection to a hint
//! that never writes. Every test below writes the trailer by hand, which is
//! exactly what a user without the hook does — and what everyone does for
//! commits that predate it, since `patch create --stamp` is still out.
//! Layer 1, the `commit-msg` hook, is in `tests/commit_msg_hook_test.rs`.

mod common;

use std::process::Command;

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

use common::TestRepo;

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

/// A `TestRepo` with a local bare `origin` it can sync against. Never a real
/// network remote. The returned `TempDir` owns the bare repo — keep it alive.
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 show_json(repo: &TestRepo, id: &str) -> Value {
    serde_json::from_str(&repo.run_ok(&["patch", "show", id, "--json"])).unwrap()
}

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

/// Create a branch with one commit whose message carries `Patch: <id>`, and a
/// patch for it. Returns the patch's short id. The trailer has to be written
/// before the patch exists to know the id, so this creates the patch first and
/// then amends the trailer in — which is what `patch create --stamp` (layer 1,
/// out of scope) would do for the user.
fn patch_with_trailer(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();
    stamp_head(repo, &short);
    repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
    repo.git(&["checkout", "main"]);
    short
}

/// Amend HEAD's message to carry a `Patch:` trailer.
fn stamp_head(repo: &TestRepo, patch_id: &str) {
    let message = repo.git(&["log", "-1", "--format=%B"]);
    let stamped = format!("{}\n\nPatch: {}\n", message.trim_end(), patch_id);
    repo.git(&["commit", "--amend", "-m", &stamped]);
}

/// Squash-merge `branch` into the current branch, keeping the source commit's
/// message (and therefore its trailer) verbatim.
fn squash_merge_keeping_message(repo: &TestRepo, branch: &str) {
    let message = repo.git(&["log", "-1", "--format=%B", branch]);
    repo.git(&["merge", "--squash", branch]);
    repo.git(&["commit", "-m", message.trim_end()]);
}

fn count_events(repo: &TestRepo, ref_name: &str) -> usize {
    repo.git(&["rev-list", "--count", ref_name])
        .trim()
        .parse()
        .unwrap()
}

fn patch_events_ref(repo: &TestRepo, short: &str) -> String {
    let out = repo.git(&[
        "for-each-ref",
        "--format=%(refname)",
        "refs/collab/patches/",
    ]);
    out.lines()
        .find(|r| r.contains(short) && r.ends_with("/events"))
        .unwrap_or_else(|| panic!("no events ref for {} in {}", short, out))
        .to_string()
}

/// The issue's ref wherever it currently lives — `close` archives it and
/// `reopen` moves it back, so neither namespace alone will do.
fn issue_ref_of(repo: &TestRepo, short: &str) -> String {
    let out = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]);
    out.lines()
        .find(|r| r.contains(short) && r.contains("issues/"))
        .unwrap_or_else(|| panic!("no issue ref for {} in {}", short, out))
        .to_string()
}

/// Like `patch_with_trailer`, but the patch declares the issue it fixes.
fn patch_fixing_issue(repo: &TestRepo, branch: &str, file: &str, issue: &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, "--fixes", issue,
    ]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    stamp_head(repo, &short);
    repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
    repo.git(&["checkout", "main"]);
    short
}

/// Run `git` in another working tree, under `env_from`'s isolated HOME so the
/// signing key and git config are the test's, not the developer's.
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()
}

/// Run `git-collab` in another working tree, same isolation as `git_in`.
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()
}

// ---------------------------------------------------------------------------
// The event
// ---------------------------------------------------------------------------

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

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

    let json = serde_json::to_string(&event).unwrap();
    assert!(json.contains("\"type\":\"patch.merge\""), "{}", json);
    assert!(
        json.contains("\"commit\":\"4b2e1cd0123456789012345678901234567890ab\""),
        "{}",
        json
    );

    let parsed: Event = serde_json::from_str(&json).unwrap();
    match parsed.action {
        Action::PatchMerge { commit } => {
            assert_eq!(commit, "4b2e1cd0123456789012345678901234567890ab")
        }
        other => panic!("expected PatchMerge, got {:?}", other),
    }
}

#[test]
fn a_patch_merge_written_before_the_commit_field_existed_still_reads() {
    // Signatures are checked by re-serializing, so a legacy `patch.merge` must
    // both deserialize and round-trip back to the bytes its signer produced.
    use git_collab::event::{Action, Event};

    let raw = r#"{"timestamp":"2026-01-01T00:00:00Z","author":{"name":"A","email":"a@b.c"},"action":{"type":"patch.merge"},"clock":2}"#;
    let parsed: Event = serde_json::from_str(raw).unwrap();
    match &parsed.action {
        Action::PatchMerge { commit } => assert_eq!(commit, ""),
        other => panic!("expected PatchMerge, got {:?}", other),
    }
    let round_tripped = serde_json::to_string(&parsed).unwrap();
    assert!(
        !round_tripped.contains("\"commit\""),
        "an empty commit must not be written back, or legacy signatures break: {}",
        round_tripped
    );
}

// ---------------------------------------------------------------------------
// Layer 3: `patch merge`
// ---------------------------------------------------------------------------

#[test]
fn patch_merge_records_the_merge() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

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

    let json = show_json(&repo, &short);
    assert_eq!(json["status"], "merged");
}

#[test]
fn patch_merge_records_the_base_tip_as_the_landing_commit() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    squash_merge_keeping_message(&repo, "feat");
    let landed = repo.git(&["rev-parse", "main"]).trim().to_string();

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

    let log = repo.run_ok(&["log"]);
    assert!(
        log.contains(&landed[..7]),
        "the recorded merge commit should be the base tip {}: {}",
        &landed[..7],
        log
    );
}

#[test]
fn merged_state_survives_branch_deletion_and_a_cold_cache() {
    // The case that fails without recording: deleting the merged branch used
    // to send the patch back to Open the moment the state cache was cleared.
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);
    repo.run_ok(&["patch", "merge", &short]);

    repo.git(&["branch", "-D", "feat"]);
    let cache = repo.dir.path().join(".git").join("collab").join("cache");
    if cache.exists() {
        std::fs::remove_dir_all(&cache).unwrap();
    }

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

#[test]
fn patch_merge_twice_records_nothing_the_second_time() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

    repo.run_ok(&["patch", "merge", &short]);
    let events_ref = patch_events_ref(&repo, &short);
    let after_first = count_events(&repo, &events_ref);

    let out = repo.run_ok(&["patch", "merge", &short]);
    assert!(
        out.contains("already"),
        "a second merge should say so, got: {}",
        out
    );
    assert_eq!(
        count_events(&repo, &events_ref),
        after_first,
        "recording a merge twice must append nothing the second time"
    );
}

#[test]
fn patch_merge_closes_the_fixed_issue() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let issue = repo.issue_open("Broken thing");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the fix");
    let out = repo.run_ok(&[
        "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
    ]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

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

    assert_eq!(issue_json(&repo, &issue)["status"], "closed");
}

// ---------------------------------------------------------------------------
// Layer 2: sync scans `Patch:` trailers on the patch's own base branch
// ---------------------------------------------------------------------------

#[test]
fn sync_records_a_squash_merge_whose_trailer_survived() {
    let (repo, _bare) = repo_with_origin();
    let short = patch_with_trailer(&repo, "feat", "a.txt");

    squash_merge_keeping_message(&repo, "feat");
    assert_eq!(
        show_json(&repo, &short)["status"],
        "open",
        "precondition: reachability cannot see a squash"
    );

    repo.run_ok(&["sync"]);

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

#[test]
fn sync_does_not_record_a_squash_whose_message_was_rewritten() {
    let (repo, _bare) = repo_with_origin();
    let short = patch_with_trailer(&repo, "feat", "a.txt");

    repo.git(&["merge", "--squash", "feat"]);
    repo.git(&["commit", "-m", "a message with no trailer at all"]);

    repo.run_ok(&["sync"]);
    assert_eq!(show_json(&repo, &short)["status"], "open");

    // Layer 3 is what covers this.
    repo.run_ok(&["patch", "merge", &short]);
    assert_eq!(show_json(&repo, &short)["status"], "merged");
}

#[test]
fn a_second_sync_after_a_recorded_merge_emits_nothing() {
    let (repo, _bare) = repo_with_origin();
    let short = patch_with_trailer(&repo, "feat", "a.txt");
    squash_merge_keeping_message(&repo, "feat");

    repo.run_ok(&["sync"]);
    let events_ref = patch_events_ref(&repo, &short);
    let after_first = count_events(&repo, &events_ref);

    repo.run_ok(&["sync"]);
    assert_eq!(
        count_events(&repo, &events_ref),
        after_first,
        "the trailer is still in history; the second scan must emit nothing"
    );
}

#[test]
fn a_trailer_on_a_branch_that_is_not_the_patches_base_does_not_record_a_merge() {
    let (repo, _bare) = repo_with_origin();
    let short = patch_with_trailer(&repo, "feat", "a.txt");

    // Land the patch on a *different* branch. `main` — the patch's base — never
    // sees the trailer.
    repo.git(&["checkout", "-b", "someone-elses-branch"]);
    squash_merge_keeping_message(&repo, "feat");
    repo.git(&["checkout", "main"]);

    repo.run_ok(&["sync"]);
    assert_eq!(show_json(&repo, &short)["status"], "open");
}

#[test]
fn an_unknown_patch_id_in_a_trailer_warns_and_leaves_sync_successful() {
    let (repo, _bare) = repo_with_origin();
    // A base branch with no open patches is not walked at all, so there has to
    // be something for the walk to be for.
    let open = patch_with_trailer(&repo, "feat", "a.txt");
    repo.commit_file(
        "x.txt",
        "x",
        "land something\n\nPatch: ffffffffffffffffffffffff",
    );

    let out = repo.run(&["sync"]);
    assert!(out.status.success(), "sync must not fail on a bad trailer");
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(
        stderr.contains("ffffffff"),
        "expected a warning naming the unresolvable id, got: {}",
        stderr
    );
    assert_eq!(show_json(&repo, &open)["status"], "open");
}

/// Give the patch whose events ref is `events_ref` a second ref under a
/// near-identical id, so any 8-character prefix of the real id matches two
/// patches. Random ids practically never collide, so ambiguity has to be
/// constructed.
fn duplicate_patch_ref_with_colliding_id(repo: &TestRepo, events_ref: &str) -> String {
    let id = events_ref
        .strip_prefix("refs/collab/patches/")
        .and_then(|r| r.strip_suffix("/events"))
        .unwrap();
    // Flip the last hex digit: everything up to it still matches.
    let last = id.chars().last().unwrap();
    let replacement = if last == '0' { '1' } else { '0' };
    let twin: String = id[..id.len() - 1].chars().chain([replacement]).collect();
    let tip = repo.git(&["rev-parse", events_ref]).trim().to_string();
    repo.git(&[
        "update-ref",
        &format!("refs/collab/patches/{}/events", twin),
        &tip,
    ]);
    twin
}

#[test]
fn an_ambiguous_patch_prefix_in_a_trailer_warns_and_leaves_sync_successful() {
    let (repo, _bare) = repo_with_origin();
    let short = patch_with_trailer(&repo, "feat", "a.txt");
    let events_ref = patch_events_ref(&repo, &short);
    duplicate_patch_ref_with_colliding_id(&repo, &events_ref);

    repo.commit_file("x.txt", "x", &format!("land something\n\nPatch: {}", short));

    let out = repo.run(&["sync"]);
    assert!(
        out.status.success(),
        "sync must not fail on an ambiguous trailer"
    );
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(
        stderr.contains("ambiguous"),
        "expected an ambiguity warning, got: {}",
        stderr
    );
    assert_eq!(count_events(&repo, &events_ref), 2, "nothing was recorded");
}

#[test]
fn a_trailer_naming_an_archived_patch_warns_and_leaves_sync_successful() {
    let (repo, _bare) = repo_with_origin();
    // One patch stays open so `main` is walked at all; the other is archived.
    let _open = patch_with_trailer(&repo, "still-open", "b.txt");
    let short = patch_with_trailer(&repo, "feat", "a.txt");
    // `patch close` archives the ref.
    repo.run_ok(&["patch", "close", &short]);
    squash_merge_keeping_message(&repo, "feat");

    let out = repo.run(&["sync"]);
    assert!(
        out.status.success(),
        "sync must not fail on an archived patch"
    );
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(
        stderr.contains("archived"),
        "expected an archived-patch warning, got: {}",
        stderr
    );
    assert_eq!(show_json(&repo, &short)["status"], "closed");
}

// ---------------------------------------------------------------------------
// `--fixes` closes its issue in the same operation that records the merge
// ---------------------------------------------------------------------------

#[test]
fn fixes_closes_the_issue_exactly_once_across_repeated_syncs() {
    let (repo, _bare) = repo_with_origin();
    let issue = repo.issue_open("Broken thing");

    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the fix");
    let out = repo.run_ok(&[
        "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
    ]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    stamp_head(&repo, &short);
    repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
    repo.git(&["checkout", "main"]);
    squash_merge_keeping_message(&repo, "feat");

    repo.run_ok(&["sync"]);
    assert_eq!(issue_json(&repo, &issue)["status"], "closed");

    let issue_ref = repo
        .git(&[
            "for-each-ref",
            "--format=%(refname)",
            "refs/collab/archive/issues/",
        ])
        .lines()
        .find(|r| r.contains(&issue))
        .unwrap()
        .to_string();
    let after_first = count_events(&repo, &issue_ref);

    repo.run_ok(&["sync"]);
    repo.run_ok(&["sync"]);
    assert_eq!(
        count_events(&repo, &issue_ref),
        after_first,
        "repeated syncs must not append a second IssueClose"
    );
}

#[test]
fn a_fixes_issue_that_is_already_closed_gets_no_further_close() {
    let (repo, _bare) = repo_with_origin();
    let issue = repo.issue_open("Broken thing");

    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the fix");
    let out = repo.run_ok(&[
        "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
    ]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    stamp_head(&repo, &short);
    repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
    repo.git(&["checkout", "main"]);

    repo.issue_close(&issue);
    let issue_ref = repo
        .git(&[
            "for-each-ref",
            "--format=%(refname)",
            "refs/collab/archive/issues/",
        ])
        .lines()
        .find(|r| r.contains(&issue))
        .unwrap()
        .to_string();
    let before = count_events(&repo, &issue_ref);

    squash_merge_keeping_message(&repo, "feat");
    repo.run_ok(&["sync"]);

    assert_eq!(show_json(&repo, &short)["status"], "merged");
    assert_eq!(
        count_events(&repo, &issue_ref),
        before,
        "the merge is recorded, but an already-closed issue gets no IssueClose"
    );
}

#[test]
fn a_close_that_did_not_happen_with_the_merge_is_retried_by_the_next_scan() {
    // The merge and the close write to different refs, so they are not atomic.
    // If the close does not land, the merge still stands — and the next scan,
    // seeing a merged patch with an open `fixes` issue, must retry rather than
    // leave the issue open forever. Simulated here by recording the merge with
    // `patch merge --no-close`, which is precisely the "merge landed, close did
    // not" state.
    let (repo, _bare) = repo_with_origin();
    let issue = repo.issue_open("Broken thing");

    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the fix");
    let out = repo.run_ok(&[
        "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
    ]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    stamp_head(&repo, &short);
    repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
    repo.git(&["checkout", "main"]);
    squash_merge_keeping_message(&repo, "feat");

    repo.run_ok(&["patch", "merge", &short, "--no-close"]);
    assert_eq!(show_json(&repo, &short)["status"], "merged");
    assert_eq!(
        issue_json(&repo, &issue)["status"],
        "open",
        "precondition: the merge landed and the close did not"
    );

    repo.run_ok(&["sync"]);
    assert_eq!(
        issue_json(&repo, &issue)["status"],
        "closed",
        "the next scan must retry the close it could not make atomic"
    );
}

// ---------------------------------------------------------------------------
// Reachability is a hint, and never writes
// ---------------------------------------------------------------------------

#[test]
fn a_reachable_patch_with_no_merge_event_shows_as_merged_with_a_question_mark() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

    let list = repo.run_ok(&["patch", "list"]);
    assert!(
        list.contains("merged?"),
        "a reachable patch with no recorded merge is a hint, not a fact: {}",
        list
    );
    assert_eq!(
        show_json(&repo, &short)["status"],
        "open",
        "the hint must not become the status"
    );
}

#[test]
fn displaying_a_reachable_patch_writes_no_event() {
    // The governing constraint: recording is never a side effect of a read.
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

    let events_ref = patch_events_ref(&repo, &short);
    let before = count_events(&repo, &events_ref);
    let tip_before = repo.git(&["rev-parse", &events_ref]).trim().to_string();

    repo.run_ok(&["patch", "list"]);
    repo.run_ok(&["patch", "show", &short]);
    repo.run_ok(&["patch", "show", &short, "--json"]);
    repo.run_ok(&["patch", "log", &short]);

    assert_eq!(count_events(&repo, &events_ref), before);
    assert_eq!(
        repo.git(&["rev-parse", &events_ref]).trim(),
        tip_before,
        "reading a patch must not move its ref"
    );
}

#[test]
fn sync_names_the_patches_that_look_merged_but_are_not_recorded() {
    let (repo, _bare) = repo_with_origin();
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

    let out = repo.run_ok(&["sync"]);
    assert!(
        out.contains(&short) && out.contains("patch merge"),
        "sync should name the patch and the command that records it: {}",
        out
    );
}

#[test]
fn a_recorded_merge_is_not_reported_as_a_hint() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);
    repo.run_ok(&["patch", "merge", &short]);

    let list = repo.run_ok(&["patch", "list", "--all"]);
    assert!(list.contains("merged"), "{}", list);
    assert!(
        !list.contains("merged?"),
        "a recorded merge is a fact, not a hint: {}",
        list
    );
}

// ---------------------------------------------------------------------------
// The walk bound must be an ancestor of *every* recorded base
// ---------------------------------------------------------------------------

#[test]
fn common_ancestor_is_an_ancestor_of_every_base_whatever_the_order() {
    // `merge_base_many` is the obvious call and the wrong one: it computes
    // `merge_base(oids[0], merge(oids[1..]))`, so on a linear chain A-B-C-D-E
    // it answers A for `[A, B, D]` but B for `[B, A, D]` — and B is not an
    // ancestor of A. Feeding it the orderings it gets wrong is the only way to
    // tell the two implementations apart.
    //
    // This needs *three* bases. For two, `merge_base_many` degenerates to the
    // symmetric binary `merge_base` and is correct, so no two-base test — at
    // this level or end to end — can distinguish them.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let mut chain = Vec::new();
    for name in ["A", "B", "C", "D", "E"] {
        chain.push(repo.commit_file("f.txt", name, name));
    }
    let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
    let oid = |s: &String| git2::Oid::from_str(s).unwrap();
    let (a, b, d) = (oid(&chain[0]), oid(&chain[1]), oid(&chain[3]));

    for order in [[a, b, d], [b, a, d], [d, a, b], [d, b, a]] {
        let bound = git_collab::merge_scan::common_ancestor(&git_repo, &order)
            .expect("a linear chain always has a common ancestor");
        for base in order {
            let is_ancestor =
                bound == base || git_repo.graph_descendant_of(base, bound).unwrap_or(false);
            assert!(
                is_ancestor,
                "bound {:.8} must be an ancestor of every base, but is not of {:.8}",
                bound, base
            );
        }
        assert_eq!(bound, a, "the bound should be the oldest base");
    }
}

#[test]
fn common_ancestor_of_nothing_is_nothing() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
    assert!(git_collab::merge_scan::common_ancestor(&git_repo, &[]).is_none());
}

#[test]
fn sync_records_a_merge_older_than_another_open_patchs_base() {
    // The multi-base bound, end to end. Three open patches share `main` with
    // bases at three points of its history, and the oldest one's merge sits
    // below the other two's bases. A bound that is not an ancestor of *every*
    // base hides that merge commit and the scan records nothing — silently, and
    // for a squash, so `looks_merged` cannot flag it either.
    let (repo, _bare) = repo_with_origin();

    // P1, based at main's first commit, squash-merged onto main.
    let p1 = patch_with_trailer(&repo, "feat1", "a.txt");
    squash_merge_keeping_message(&repo, "feat1");

    // main moves on, and P2 is based there.
    repo.commit_file("upstream1.txt", "u1", "upstream work");
    let p2 = patch_with_trailer(&repo, "feat2", "b.txt");

    // main moves again, and P3 is based there. Three distinct bases.
    repo.commit_file("upstream2.txt", "u2", "more upstream work");
    let p3 = patch_with_trailer(&repo, "feat3", "c.txt");

    repo.run_ok(&["sync"]);

    assert_eq!(
        show_json(&repo, &p1)["status"],
        "merged",
        "the merge sits below the other patches' bases; the bound must not hide it"
    );
    assert_eq!(show_json(&repo, &p2)["status"], "open");
    assert_eq!(show_json(&repo, &p3)["status"], "open");
}

// ---------------------------------------------------------------------------
// The retry must not override a deliberate reopen
// ---------------------------------------------------------------------------

#[test]
fn an_issue_reopened_after_a_merge_closed_it_stays_open() {
    // Reopening a merged patch's issue is ordinary — the fix landed and turned
    // out to be wrong. The retry exists to restore a close that never happened;
    // it must never override a decision made after one did. Without the reopen
    // clause this re-closes on every sync, forever, and silently.
    let (repo, _bare) = repo_with_origin();
    let issue = repo.issue_open("Broken thing");
    let _short = patch_fixing_issue(&repo, "feat", "a.txt", &issue);
    squash_merge_keeping_message(&repo, "feat");

    repo.run_ok(&["sync"]);
    assert_eq!(issue_json(&repo, &issue)["status"], "closed");

    repo.run_ok(&["issue", "reopen", &issue]);
    assert_eq!(issue_json(&repo, &issue)["status"], "open");
    let after_reopen = count_events(&repo, &issue_ref_of(&repo, &issue));

    repo.run_ok(&["sync"]);
    repo.run_ok(&["sync"]);

    assert_eq!(
        issue_json(&repo, &issue)["status"],
        "open",
        "a deliberate reopen must survive the retry"
    );
    assert_eq!(
        count_events(&repo, &issue_ref_of(&repo, &issue)),
        after_reopen,
        "and nothing should have been appended trying"
    );
}

#[test]
fn patch_merge_does_not_reclose_an_issue_reopened_after_its_merge() {
    // The same guard on the hand-recorded path. `patch merge` on an
    // already-merged patch is a no-op, and it must not smuggle a re-close in.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let issue = repo.issue_open("Broken thing");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the fix");
    let out = repo.run_ok(&[
        "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
    ]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);

    repo.run_ok(&["patch", "merge", &short]);
    assert_eq!(issue_json(&repo, &issue)["status"], "closed");

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

    assert_eq!(issue_json(&repo, &issue)["status"], "open");
}

#[test]
fn a_reopened_issue_is_still_closed_by_a_different_merged_patch() {
    // The guard is scoped to the patch whose close was overridden, not to the
    // issue. Another patch naming the same issue has had no decision of its own
    // overridden, so its close still fires.
    let (repo, _bare) = repo_with_origin();
    let issue = repo.issue_open("Broken thing");

    let first = patch_fixing_issue(&repo, "feat1", "a.txt", &issue);
    squash_merge_keeping_message(&repo, "feat1");
    repo.run_ok(&["sync"]);
    assert_eq!(issue_json(&repo, &issue)["status"], "closed");

    repo.run_ok(&["issue", "reopen", &issue]);
    let second = patch_fixing_issue(&repo, "feat2", "b.txt", &issue);
    squash_merge_keeping_message(&repo, "feat2");

    repo.run_ok(&["sync"]);

    assert_eq!(show_json(&repo, &first)["status"], "merged");
    assert_eq!(show_json(&repo, &second)["status"], "merged");
    assert_eq!(
        issue_json(&repo, &issue)["status"],
        "closed",
        "the second patch's close has not been overridden by anyone"
    );
}

// ---------------------------------------------------------------------------
// Two clones recording the same merge converge
// ---------------------------------------------------------------------------

#[test]
fn two_clones_recording_a_merge_concurrently_converge() {
    let (alice, bare) = repo_with_origin();
    alice.git(&["checkout", "-b", "feat"]);
    alice.commit_file("a.txt", "x", "the patch");
    let out = alice.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    alice.git(&["checkout", "main"]);
    alice.git(&["merge", "--ff-only", "feat"]);
    alice.git(&["push", "origin", "main"]);
    alice.run_ok(&["sync"]);

    // Bob clones the same bare remote and picks up the patch.
    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"]);

    // Both record the merge without having seen the other's event.
    alice.run_ok(&["patch", "merge", &short]);
    collab_in(&alice, &bob, &["patch", "merge", &short]);

    // Alice pushes first, so Bob's sync has to reconcile a genuinely divergent
    // DAG rather than fast-forward.
    alice.run_ok(&["sync"]);
    collab_in(&alice, &bob, &["sync"]);
    alice.run_ok(&["sync"]);

    let bob_json: Value = serde_json::from_str(&collab_in(
        &alice,
        &bob,
        &["patch", "show", &short, "--json"],
    ))
    .unwrap();
    assert_eq!(show_json(&alice, &short)["status"], "merged");
    assert_eq!(bob_json["status"], "merged");

    let events_ref = patch_events_ref(&alice, &short);
    assert_eq!(
        alice.git(&["rev-parse", &events_ref]).trim(),
        git_in(&alice, &bob, &["rev-parse", &events_ref]).trim(),
        "both clones must end at the same DAG tip"
    );
}

// ---------------------------------------------------------------------------
// The hint is reported in --json too: beside the status, never inside it
// ---------------------------------------------------------------------------

#[test]
fn json_reports_the_merge_hint_beside_the_status() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);

    // Not merged: no hint at all, so its absence never reads as an assertion.
    let json = show_json(&repo, &short);
    assert_eq!(json["status"], "open");
    assert!(json.get("looks_merged").is_none(), "{}", json);

    repo.git(&["merge", "--ff-only", "feat"]);

    let json = show_json(&repo, &short);
    assert_eq!(json["status"], "open", "the hint is never the status");
    assert_eq!(json["looks_merged"], true);

    let list: Value = serde_json::from_str(&repo.run_ok(&["patch", "list", "--json"])).unwrap();
    assert_eq!(list[0]["status"], "open");
    assert_eq!(
        list[0]["looks_merged"], true,
        "list and show render the same entries; the hint has to be in both"
    );

    // Once recorded it is a fact, and the hint goes away.
    repo.run_ok(&["patch", "merge", &short]);
    let json = show_json(&repo, &short);
    assert_eq!(json["status"], "merged");
    assert!(json.get("looks_merged").is_none(), "{}", json);
}

// ---------------------------------------------------------------------------
// The recorded merge commit has to reach the views
// ---------------------------------------------------------------------------

#[test]
fn the_recorded_merge_commit_reaches_show_json_and_patch_log() {
    // The commit is the reason it is in the event at all: it is what a UI links
    // to, and for a squash it is the only route from the patch back to the code
    // — the patch's own commits are ancestors of nothing on the base branch.
    // Folding it to a bare `status = merged` threw that away.
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    squash_merge_keeping_message(&repo, "feat");
    let landed = repo.git(&["rev-parse", "main"]).trim().to_string();

    // Before the merge is recorded there is no commit to report, and the field
    // must not read as an assertion that one exists.
    assert!(
        show_json(&repo, &short)["merge_commit"].is_null(),
        "an unmerged patch has no merge commit"
    );

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

    assert_eq!(
        show_json(&repo, &short)["merge_commit"],
        landed,
        "the recorded commit must survive the fold into `patch show --json`"
    );

    let list: Value =
        serde_json::from_str(&repo.run_ok(&["patch", "list", "--json", "--all"])).unwrap();
    assert_eq!(
        list[0]["merge_commit"], landed,
        "list and show render the same entries; the commit has to be in both"
    );

    let show = repo.run_ok(&["patch", "show", &short]);
    assert!(
        show.contains(&landed[..8]),
        "`patch show` must name the commit that landed the patch: {}",
        show
    );

    let log = repo.run_ok(&["patch", "log", &short]);
    assert!(
        log.contains(&landed[..8]),
        "`patch log` must name the commit that landed the patch: {}",
        log
    );
}

#[test]
fn displaying_a_merged_patch_writes_no_event() {
    // The governing constraint, on the path that now renders the merge commit.
    // Surfacing a recorded fact must stay a pure read.
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "the patch");
    let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);
    repo.run_ok(&["patch", "merge", &short]);

    let events_ref = patch_events_ref(&repo, &short);
    let before = count_events(&repo, &events_ref);
    let tip_before = repo.git(&["rev-parse", &events_ref]).trim().to_string();

    repo.run_ok(&["patch", "list", "--all"]);
    repo.run_ok(&["patch", "show", &short]);
    repo.run_ok(&["patch", "show", &short, "--json"]);
    repo.run_ok(&["patch", "list", "--json", "--all"]);
    repo.run_ok(&["patch", "log", &short]);

    assert_eq!(count_events(&repo, &events_ref), before);
    assert_eq!(
        repo.git(&["rev-parse", &events_ref]).trim(),
        tip_before,
        "reading a merged patch must not move its ref"
    );
}

#[test]
fn two_clones_recording_different_merge_commits_agree_on_one() {
    // Concurrent recordings name different commits — one person points at the
    // squash commit, another at a later one. `(clock, oid)` decides, exactly as
    // it does for the status, so both clones fold the same event set to the
    // same answer. A merged patch whose commit differed per clone would be
    // worse than none: it is what a UI links to.
    let (alice, bare) = repo_with_origin();
    alice.git(&["checkout", "-b", "feat"]);
    alice.commit_file("a.txt", "x", "the patch");
    let out = alice.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();
    alice.git(&["checkout", "main"]);
    alice.git(&["merge", "--ff-only", "feat"]);
    let first = alice.git(&["rev-parse", "main"]).trim().to_string();
    // A second commit on main, so the two clones have two real commits to
    // disagree about.
    let second = alice.commit_file("b.txt", "y", "later work");
    alice.git(&["push", "origin", "main"]);
    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"]);

    assert_ne!(first, second, "the two commits must actually differ");

    // Neither has seen the other's event.
    alice.run_ok(&["patch", "merge", &short, "--commit", &first]);
    collab_in(
        &alice,
        &bob,
        &["patch", "merge", &short, "--commit", &second],
    );

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

    let alice_commit = show_json(&alice, &short)["merge_commit"].clone();
    let bob_json: Value = serde_json::from_str(&collab_in(
        &alice,
        &bob,
        &["patch", "show", &short, "--json"],
    ))
    .unwrap();

    assert!(
        alice_commit == Value::String(first.clone()) || alice_commit == Value::String(second),
        "the winner must be one of the two recorded commits, got {}",
        alice_commit
    );
    assert_eq!(
        alice_commit, bob_json["merge_commit"],
        "both clones must agree on which commit landed the patch"
    );
}

// ---------------------------------------------------------------------------
// The local scans do not need a remote
// ---------------------------------------------------------------------------

#[test]
fn a_repo_with_no_remote_records_a_merge_and_links_a_commit() {
    // Both scans are purely local: they walk local refs and append local
    // events. Nothing about either needs a remote. A single clone with no
    // remote is the most basic case of the project's premise that
    // collaboration lives in the repository — and it is the state everyone is
    // in *before* adding a remote, so this is also the first impression.
    let repo = TestRepo::new("Alice", "alice@example.com");
    // Deliberately no `git remote add` and no `git-collab init`.

    let issue = repo.issue_open("Broken thing");
    let short = patch_with_trailer(&repo, "feat", "a.txt");
    squash_merge_keeping_message(&repo, "feat");
    let linked = repo.commit_file("b.txt", "y", &format!("unrelated fix\n\nIssue: {}", issue));

    let out = repo.run_ok(&["sync"]);

    assert_eq!(
        show_json(&repo, &short)["status"],
        "merged",
        "a repo with no remote must still record a merge from a Patch: trailer.\nsync said: {}",
        out
    );

    let linked_commits = issue_json(&repo, &issue)["linked_commits"]
        .as_array()
        .expect("issue JSON should carry linked_commits")
        .iter()
        .map(|c| c["commit"].as_str().unwrap_or_default().to_string())
        .collect::<Vec<_>>();
    assert!(
        linked_commits.contains(&linked),
        "a repo with no remote must still link a commit from an Issue: trailer, \
         got {:?}.\nsync said: {}",
        linked_commits,
        out
    );
}

#[test]
fn sync_with_no_remote_reports_what_it_did_instead_of_only_an_instruction() {
    // Returning at the no-remotes gate printed a bare instruction and skipped
    // the local half of sync's job entirely. It should still do that half and
    // say so — while still pointing at `init`, since nothing is being shared.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_with_trailer(&repo, "feat", "a.txt");
    squash_merge_keeping_message(&repo, "feat");

    let out = repo.run_ok(&["sync"]);

    assert!(
        out.contains("Recorded 1 merged patch(es)."),
        "sync must report the merge it recorded: {}",
        out
    );
    assert!(
        out.contains("git-collab init"),
        "and still say how to get a remote: {}",
        out
    );
    assert_eq!(show_json(&repo, &short)["status"], "merged");
}

#[test]
fn a_second_sync_with_no_remote_records_nothing_further() {
    // The no-remote path appends events, so it has to be as idempotent as the
    // remote one: a trailer stays in history forever.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = patch_with_trailer(&repo, "feat", "a.txt");
    squash_merge_keeping_message(&repo, "feat");

    repo.run_ok(&["sync"]);
    let events_ref = patch_events_ref(&repo, &short);
    let after_first = count_events(&repo, &events_ref);

    repo.run_ok(&["sync"]);
    repo.run_ok(&["sync"]);

    assert_eq!(
        count_events(&repo, &events_ref),
        after_first,
        "repeated syncs with no remote must append nothing further"
    );
}