Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/forge_merge.rs

#![cfg(all(feature = "server", feature = "control"))]

//! **The review gate on `PullRequests.Merge`** (roadmap phase 1).
//!
//! A change proposal becomes a branch that moved, and this file is the single
//! assertion the whole two-halves design exists to make: **the ref only moves on
//! evidence, and the evidence has to be about the commit that is about to
//! ship.**
//!
//! The merge lives on the record half — `merge_pull` on the real
//! [`CoreControlPlane`], the same call `PullRequests.Merge` makes — and the
//! evidence lives on the review half, as `sshsig`-signed events. The gate is
//! where the two meet, and these are the five arms of it:
//!
//! * nobody has approved it → refused;
//! * an approval by a key nobody registered → refused, which is the same
//!   refusal for a *different reason* and therefore its own arm: the approval
//!   is there, it names the right commit and its signature is perfectly good.
//!   It is signed by a key the identity table has nothing to check it against, which
//!   makes it a claim and not evidence;
//! * a verified approval of the tip → merges, and the record says so;
//! * **the source branch advanced after the review** → refused, because the
//!   approval names the commit that was reviewed and the branch has moved past
//!   it. That is the arm that matters. It is the property a mutable
//!   `approved: bool` destroys — a bit that follows the branch would let a
//!   force-push after a review ship code nobody read — and it is why the
//!   approval names a commit at all;
//! * not a fast-forward → since GUN-MERGE-ENGINE (2026-11-01) a merge commit,
//!   authorised by the approvals of the head it merges, refused on a conflict and
//!   re-checked when the target moves (the arms under "THE MERGE ENGINE").
//!
//! **Every refusal asserts the ref did not move**, read back through the
//! store's own `refs()`, and that the record is still open. A merge that
//! refused and moved the branch anyway is indistinguishable from this one from
//! the caller's side, and it is the branch that ships.
//!
//! The gate is deliberately checked against `source` — the tip **now** — and not
//! against `record.source_oid`, which is the tip at *creation*. `proposed()`
//! below asserts those two are the same at the start, so the force-push arm is
//! genuinely watching them come apart.

mod common;

use std::sync::Arc;

use common::{tools_present, Harness};
use gunnar_cli::server::CoreControlPlane;
use gunnar_core::forge::{sign_event, Event, ForgeEvent};
use gunnar_core::HashKind;
use gunnar_grpc::{ControlError, ControlPlane, MergeOptions};
use gunnar_proto::{PullRequestState, RepoPath};

const NAMESPACE: &str = "team";
const NAME: &str = "proposed";
/// How the forge log and the pulls table both spell this repository.
const REPO: &str = "team/proposed";
const SOURCE: &str = "refs/heads/topic";
const TARGET: &str = "refs/heads/main";

/// The repository the control plane is asked about.
fn path() -> RepoPath {
    RepoPath {
        namespace: NAMESPACE.into(),
        repo: NAME.into(),
    }
}

/// The real control plane over the harness's real stores — the same object the
/// `PullRequests` service holds, so what is exercised here is the shipped
/// `merge_pull` and not a test double of it.
fn control_plane(harness: &Harness) -> CoreControlPlane {
    CoreControlPlane::new(Arc::clone(&harness.stores))
}

/// Put a signed event in the forge log, by hand.
///
/// Deliberately NOT through `Forge.Append`: the unregistered-key arm needs an
/// approval in the log that the server would never have accepted, and appending
/// it here is the only way to ask what the *merge* does when it finds one.
/// `forge_service.rs` is where the append path's own refusals are asserted; if
/// this wrote through it, the two suites would be testing the same check twice
/// and neither would be testing the gate.
fn append(harness: &Harness, signed: &gunnar_core::SignedEvent) {
    use std::io::Write as _;
    let mut line = serde_json::to_string(signed).expect("json");
    line.push('\n');
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(harness.stores.forge_log().records_path())
        .expect("open the forge log");
    file.write_all(line.as_bytes()).expect("append");
}

fn event(pull: u64, actor: &str, at: i64, event: Event) -> ForgeEvent {
    ForgeEvent {
        repo: REPO.into(),
        pull,
        actor: actor.into(),
        at_unix_ms: at,
        event,
    }
}

/// `actor` approves `commit` on pull request `pull`, signed with `key`.
fn approve(
    harness: &Harness,
    key: &gunnar_core::ssh_key::PrivateKey,
    pull: u64,
    actor: &str,
    at: i64,
    commit: &str,
) {
    let signed = sign_event(
        key,
        event(
            pull,
            actor,
            at,
            Event::Approved {
                commit: commit.to_owned(),
            },
        ),
    )
    .expect("sign the approval");
    append(harness, &signed);
}

/// Where a ref points right now, read out of the server's own store.
///
/// The store and not the record: the record is what the merge *writes*, so
/// asking it whether the merge happened would be asking the accused. What ships
/// is the ref.
fn tip(harness: &Harness, name: &str) -> Option<String> {
    let repo = gunnar_core::RepoRef::new(NAMESPACE, NAME).expect("a legal repo path");
    let entry = harness
        .stores
        .catalog()
        .get(&repo)
        .expect("read the catalogue")
        .expect("the repository exists");
    let store = harness.stores.repo_store(&entry).expect("open the store");
    store
        .serve()
        .refs()
        .expect("read the refs")
        .into_iter()
        .find(|row| row.name == name)
        .and_then(|row| row.oid)
        .map(|oid| oid.iter().map(|b| format!("{b:02x}")).collect())
}

/// What the state of one pull request reads back as, through the plane.
fn state_of(plane: &CoreControlPlane, number: u64) -> i32 {
    plane
        .get_pull(&path(), number)
        .expect("read the pull request back")
        .state
}

/// The fixture: `main` at one commit, `refs/heads/topic` one commit ahead of it,
/// and an open pull request from the second into the first.
struct Proposal {
    /// Where `main` is — and where it must still be after every refusal.
    base: String,
    /// The reviewed tip of `topic`.
    topic: String,
    /// The number `create_pull` minted. Everything else is signed against it.
    number: u64,
}

fn proposed(harness: &Harness, plane: &CoreControlPlane) -> Proposal {
    proposed_with(harness, plane, false)
}

/// [`proposed`], every commit SSH-signed by the harness key when `signed` (the work
/// tree signs from then on: `main_moves_on` too).
fn proposed_with(harness: &Harness, plane: &CoreControlPlane, signed: bool) -> Proposal {
    let work = harness.dir.path().join("work");
    let url = harness.url(NAMESPACE, NAME);
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", work.to_str().unwrap()],
        )
        .expect_ok("git init");
    if signed {
        let key = harness
            .key_path
            .to_str()
            .expect("a utf-8 key path")
            .to_owned();
        for (name, value) in [
            ("gpg.format", "ssh"),
            ("user.signingkey", key.as_str()),
            ("commit.gpgsign", "true"),
        ] {
            harness
                .git(&work, &["config", name, value])
                .expect_ok("sign the work tree's commits");
        }
    }
    std::fs::write(work.join("a.txt"), "base\n").expect("write");
    harness.git(&work, &["add", "."]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "base"])
        .expect_ok("commit");
    harness
        .git(&work, &["push", "--quiet", &url, "main"])
        .expect_ok("push main");
    let base = harness.git(&work, &["rev-parse", "HEAD"]);
    base.expect_ok("rev-parse");
    let base = base.stdout.trim().to_owned();

    harness
        .git(&work, &["checkout", "--quiet", "-b", "topic"])
        .expect_ok("branch");
    std::fs::write(work.join("a.txt"), "base\nproposed\n").expect("write");
    harness.git(&work, &["add", "."]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "the proposal"])
        .expect_ok("commit");
    harness
        .git(&work, &["push", "--quiet", &url, "topic"])
        .expect_ok("push topic");
    let topic = harness.git(&work, &["rev-parse", "HEAD"]);
    topic.expect_ok("rev-parse");
    let topic = topic.stdout.trim().to_owned();

    assert_eq!(
        tip(harness, TARGET).as_deref(),
        Some(base.as_str()),
        "the fixture did not land: main is not where the push put it"
    );
    assert_eq!(
        tip(harness, SOURCE).as_deref(),
        Some(topic.as_str()),
        "the fixture did not land: topic is not where the push put it"
    );

    let record = plane
        .create_pull(
            &path(),
            "tester",
            "the proposal",
            "please look",
            SOURCE,
            TARGET,
        )
        .expect("open the pull request");
    assert_eq!(
        record.source_oid, topic,
        "the record anchors on the tip at creation; the force-push arm is about \
         these two coming apart, so they must start together"
    );

    Proposal {
        base,
        topic,
        number: record.number,
    }
}

/// **Merge, expecting a refusal — and check the whole refusal.**
///
/// It is a precondition failure, the message says each of `must_say`, the
/// target ref is still exactly `target`, and the record is still open. The
/// last two assert applied output, and they are inside this helper on purpose:
/// an arm cannot assert the error and forget the effect, because a merge that
/// refused and moved the branch anyway returns the same `Err` this one does.
///
/// Returns the message, so an arm can say more about it.
fn refuses(
    harness: &Harness,
    plane: &CoreControlPlane,
    number: u64,
    target: &str,
    must_say: &[&str],
) -> String {
    let err = plane
        .merge_pull(&path(), number, "tester", &MergeOptions::default())
        .expect_err("the merge was not refused");
    assert!(
        matches!(err, ControlError::FailedPrecondition(_)),
        "a merge refused on the state of the world is a failed precondition, not \
         a malformed request or a server fault: {err:?}"
    );
    let message = format!("{err}");
    for phrase in must_say {
        assert!(
            message.contains(*phrase),
            "the refusal must say {phrase:?} — an operator who cannot tell WHY it \
             refused cannot tell whether to get an approval or to rebase: {message}"
        );
    }
    assert_eq!(
        tip(harness, TARGET).as_deref(),
        Some(target),
        "the merge refused and moved {TARGET} anyway"
    );
    assert_eq!(
        state_of(plane, number),
        PullRequestState::Open as i32,
        "the merge refused and marked the record merged anyway"
    );
    message
}

/// **A pull request nobody has approved does not merge, and `main` stays put.**
///
/// The first and plainest reading of the gate: no evidence, no ref move. What is
/// asserted beyond the refusal is the *sentence* — an operator who is told
/// "cannot merge" and nothing else has to guess between "get a review" and
/// "rebase", and the two have nothing to do with each other.
#[test]
fn a_pull_request_nobody_approved_does_not_merge() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &[
            "no verified approval names",
            "Nobody has approved this pull request",
        ],
    );
    assert!(
        message.contains(&proposal.topic[..12]),
        "the refusal must name the commit it would have moved to, so the reviewer \
         can be asked about that one: {message}"
    );

    harness.stop();
}

/// **An approval by a key nobody registered does not merge either.**
///
/// A different reason from the arm above and therefore a separate arm: here
/// there *is* an approval, it names exactly the commit the merge is about to
/// move to, and its signature is perfectly good. It is signed by a key the
/// identity table has nothing to check it against, so it is a stranger's claim that the
/// code is fine, and a claim is not evidence. `review_state` re-checks every
/// approval against the key table at merge time — which is also why an approval
/// by a key that has since been revoked stops counting the moment the key does.
///
/// Red first, then the same commit approved by a **registered** key, so the
/// refusal above is demonstrably about the key and not about the merge being
/// broken.
#[test]
fn an_approval_by_an_unregistered_key_does_not_merge() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    let stranger = gunnar_core::ssh_key::PrivateKey::random(
        &mut gunnar_core::os_rng(),
        gunnar_core::ssh_key::Algorithm::Ed25519,
    )
    .expect("mint a key nobody registered");
    approve(
        &harness,
        &stranger,
        proposal.number,
        "stranger",
        1_700_000_001_000,
        &proposal.topic,
    );

    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &[
            "no verified approval names",
            "What is approved:",
            "stranger",
        ],
    );
    assert!(
        message.contains("UNKNOWN KEY"),
        "the refusal must say the approval was not checkable rather than that it \
         was missing: an operator who is told nobody approved it, when somebody \
         did, looks for the wrong problem: {message}"
    );

    // The same commit, approved by the key the identity table knows. Nothing else
    // changes, so this is the key and only the key.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a registered key's approval of the tip must merge");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the approval verified and the branch did not move"
    );

    harness.stop();
}

/// **A verified approval of the commit being shipped merges**, `main`
/// fast-forwards to the source tip, and the record reads back merged at that
/// oid.
///
/// Red first, and the red is not "no approval" — this arm's refusal is an
/// approval of the **wrong commit**: `tester` signs off on the commit `main` is
/// already at, which is a real commit, really reviewed, and not the one the
/// merge would move to. That is the shape of the gate in one line: the question
/// is never "is there an approval", it is "is there one for *this* commit".
#[test]
fn a_verified_approval_of_the_tip_fast_forwards_the_branch() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.base,
    );
    refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &["no verified approval names", &proposal.topic[..12]],
    );

    // Now the tip itself. A later `Approved` by the same actor replaces the
    // earlier one in the fold, so what the gate sees is one approval, of the
    // commit that is about to ship.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );

    let (merged, moved) = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("an approved fast-forward must merge");

    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the merge succeeded and {TARGET} did not move (it was at {})",
        proposal.base
    );
    assert_eq!(moved.ref_name, TARGET);
    assert_eq!(
        (moved.old_oid.as_str(), moved.new_oid.as_str()),
        (proposal.base.as_str(), proposal.topic.as_str()),
        "what the plane reports moving must be what actually moved: this is the \
         fact the service turns into the same `RefUpdated` a push publishes"
    );
    assert_eq!(merged.state, PullRequestState::Merged as i32);
    assert_eq!(
        merged.merge_oid, proposal.topic,
        "the record must name the oid the ref was moved to, not the tip the pull \
         request was opened at"
    );
    assert_eq!(
        state_of(&plane, proposal.number),
        PullRequestState::Merged as i32,
        "the record read back fresh must agree with what the merge returned"
    );

    // And a second merge is refused rather than moving the branch again. Not
    // through `refuses`: the record is legitimately no longer open here, which
    // is the very thing being asserted.
    let err = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect_err("a merged pull request merged twice");
    assert!(
        format!("{err}").contains("not open"),
        "the second refusal must name the state, not just decline: {err}"
    );
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the second merge moved the branch again"
    );

    harness.stop();
}

/// **A force-push does not inherit the review, and the merge refuses.**
///
/// This is the arm the whole design is for. The tip is approved, by a
/// registered key, verifiably — and then the branch grows a commit nobody has
/// looked at. The gate reads the tip **now**, not `record.source_oid` (the tip
/// at creation) and not a stored boolean, so the approval no longer names what
/// would ship and the merge is refused.
///
/// A mutable `approved: bool` on the record cannot express this. It is true both
/// before and after the push, and the branch ships with an unreviewed commit on
/// it while every screen in the forge says the change was approved. An approval
/// that names a commit is what makes the two states different, and this arm is
/// where that difference becomes a refusal instead of a display.
///
/// Red first, and the green half is the same pull request approved again at its
/// new tip: the reviewer looked at the new commit and said so, which is exactly
/// the remedy the refusal points at, and it proves the refusal was about the
/// commit rather than about anything having gone wrong.
#[test]
fn a_branch_that_advanced_after_the_review_is_not_merged() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let work = harness.dir.path().join("work");

    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.topic,
    );

    // The review is in. Now the branch moves — the ordinary, innocent-looking
    // case: one more commit pushed onto the same branch after the approval.
    std::fs::write(work.join("a.txt"), "base\nproposed\nand one more thing\n").expect("write");
    harness.git(&work, &["add", "."]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "after the review"])
        .expect_ok("commit");
    harness
        .git(
            &work,
            &["push", "--quiet", &harness.url(NAMESPACE, NAME), "topic"],
        )
        .expect_ok("push topic again");
    let advanced = harness.git(&work, &["rev-parse", "HEAD"]);
    advanced.expect_ok("rev-parse");
    let advanced = advanced.stdout.trim().to_owned();
    assert_ne!(
        advanced, proposal.topic,
        "the branch did not actually move, so this arm proves nothing"
    );
    assert_eq!(
        tip(&harness, SOURCE).as_deref(),
        Some(advanced.as_str()),
        "the second push did not land"
    );

    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &[
            "no verified approval names",
            &advanced[..12],
            "What is approved:",
            &proposal.topic[..12],
        ],
    );
    assert!(
        message.contains("[verified]"),
        "the refusal must say the approval it found is good and simply about \
         another commit — otherwise the reviewer is told their signature failed: \
         {message}"
    );
    assert!(
        message.contains("an approval names the commit it approved"),
        "and it must say why, because 'it is approved, look at the page' is the \
         objection this refusal always meets: {message}"
    );

    // The reviewer looks at the new commit and approves that one.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_003_000,
        &advanced,
    );
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("an approval of the new tip must merge it");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(advanced.as_str()),
        "the reviewed tip did not ship"
    );

    harness.stop();
}

// ── THE MERGE ENGINE (GUN-MERGE-ENGINE, 2026-11-01) ─────────────────────────
//
// The owner hit it on gunnar.rs: a pull request whose branch did not descend
// from main could not be merged ("gunnar has no merge engine yet"). The arms
// below are the engine's contract, each asserted from the store and from a
// REAL git client that clones the result:
//
// * a branch behind its target merges with a merge commit (two parents), which
//   `git fsck` accepts and `git log --graph` draws as a merge;
// * a conflict is refused by path, and nothing moves;
// * a target that moved after the check is checked again against the new tip;
// * the approval rule: approvals of the HEAD authorise merging that head into
//   the target tip, and the merge commit records head, target and approvers;
// * the repository's merge policy (fast-forward only, squash) is obeyed.

/// `main` moves on, on the work tree's `main`, with `files` written; answers the new tip.
fn main_moves_on(harness: &Harness, files: &[(&str, &str)], message: &str) -> String {
    let work = harness.dir.path().join("work");
    harness
        .git(&work, &["checkout", "--quiet", "main"])
        .expect_ok("checkout main");
    for (name, body) in files {
        std::fs::write(work.join(name), body).expect("write");
    }
    harness.git(&work, &["add", "."]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", message])
        .expect_ok("commit");
    harness
        .git(
            &work,
            &["push", "--quiet", &harness.url(NAMESPACE, NAME), "main"],
        )
        .expect_ok("push main again");
    let moved = harness.git(&work, &["rev-parse", "HEAD"]);
    moved.expect_ok("rev-parse");
    let moved = moved.stdout.trim().to_owned();
    assert_eq!(
        tip(harness, TARGET).as_deref(),
        Some(moved.as_str()),
        "main did not move"
    );
    moved
}

/// A fresh clone of what the server holds, by a real git client.
fn clone_of(harness: &Harness, name: &str) -> std::path::PathBuf {
    let dst = harness.dir.path().join(name);
    harness
        .git(
            harness.dir.path(),
            &[
                "clone",
                "--quiet",
                &harness.url(NAMESPACE, NAME),
                dst.to_str().unwrap(),
            ],
        )
        .expect_ok("clone the merged repository");
    dst
}

fn git_out(harness: &Harness, dir: &std::path::Path, args: &[&str]) -> String {
    let out = harness.git(dir, args);
    out.expect_ok(&args.join(" "));
    out.stdout
}

fn strategy(merged: &gunnar_grpc::MergedRef) -> gunnar_proto::MergeStrategy {
    merged.strategy
}

/// **A branch behind its target merges with a merge commit, and a real git client
/// sees a correct merge.**
///
/// `main` moved on (a new file) after `topic` forked. The review gate still runs
/// first: an approval of the commit `main` is at is not an approval of the head
/// and does not authorise the merge. With the head approved, the check answers a
/// merge commit with no conflicts and moves nothing; the merge writes a commit
/// whose first parent is the target tip and whose second is the approved head,
/// authored by the person who pressed Merge, committed by gunnar, and saying in
/// its trailers what authorised it. Then a clone: `git fsck --strict` is clean,
/// `git rev-list --parents` names both parents, `git log --graph` draws the
/// merge, and the tree holds both sides' work.
#[test]
fn a_branch_behind_its_target_merges_with_a_merge_commit_git_accepts() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let moved = main_moves_on(&harness, &[("b.txt", "meanwhile\n")], "meanwhile");

    // The approval names the TARGET's tip, not the head: no authority to merge the head.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &moved,
    );
    refuses(
        &harness,
        &plane,
        proposal.number,
        &moved,
        &["no verified approval names", &proposal.topic[..12]],
    );
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );

    let (pull, would) = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("an approved branch behind its target checks as mergeable");
    assert_eq!(pull.state, PullRequestState::Open as i32);
    assert_eq!(
        strategy(&would),
        gunnar_proto::MergeStrategy::MergeCommit,
        "behind its target, the default is a merge commit"
    );
    assert!(would.conflicts.is_empty(), "{:?}", would.conflicts);
    assert_eq!(
        (would.old_oid.as_str(), would.head_oid.as_str()),
        (moved.as_str(), proposal.topic.as_str())
    );
    assert!(
        would.new_oid.is_empty(),
        "a check names no commit it has not written"
    );
    assert_eq!(would.approvers, vec!["tester".to_owned()]);
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(moved.as_str()),
        "the check moved main"
    );

    let (merged, result) = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("an approved branch behind its target merges");
    assert_eq!(strategy(&result), gunnar_proto::MergeStrategy::MergeCommit);
    assert_eq!(result.old_oid, moved);
    let merge = result.new_oid.clone();
    assert!(
        merge != moved && merge != proposal.topic,
        "no new commit: {merge}"
    );
    assert_eq!(tip(&harness, TARGET).as_deref(), Some(merge.as_str()));
    assert_eq!(merged.state, PullRequestState::Merged as i32);
    assert_eq!(merged.merge_oid, merge, "the record names the merge commit");

    // A real git client reads it.
    let clone = clone_of(&harness, "merged");
    git_out(
        &harness,
        &clone,
        &["fsck", "--full", "--strict", "--no-dangling"],
    );
    let parents = git_out(&harness, &clone, &["rev-list", "--parents", "-n1", "HEAD"]);
    assert_eq!(
        parents.split_whitespace().collect::<Vec<_>>(),
        vec![merge.as_str(), moved.as_str(), proposal.topic.as_str()],
        "the merge commit's parents are the target tip, then the approved head"
    );
    let graph = git_out(&harness, &clone, &["log", "--graph", "--format=%h %s"]);
    assert!(
        graph.lines().next().unwrap_or("").starts_with("*   ") && graph.contains("|\\"),
        "git log --graph does not draw a merge:\n{graph}"
    );
    assert_eq!(
        std::fs::read_to_string(clone.join("a.txt")).unwrap(),
        "base\nproposed\n"
    );
    assert_eq!(
        std::fs::read_to_string(clone.join("b.txt")).unwrap(),
        "meanwhile\n"
    );

    let tester = harness
        .stores
        .auth()
        .principal("tester")
        .unwrap()
        .expect("the harness principal");
    let author = git_out(&harness, &clone, &["log", "-1", "--format=%an <%ae>"]);
    assert_eq!(
        author.trim(),
        format!("{} <tester@gunnar.invalid>", tester.name),
        "the author is the person who pressed Merge"
    );
    let committer = git_out(&harness, &clone, &["log", "-1", "--format=%cn <%ce>"]);
    assert_eq!(committer.trim(), "Gunnar <noreply@gunnar.invalid>");
    let trailers = git_out(
        &harness,
        &clone,
        &["log", "-1", "--format=%(trailers:only,unfold)"],
    );
    for line in [
        format!("Pull-request: #{}", proposal.number),
        format!("Head: {}", proposal.topic),
        format!("Target: {moved}"),
        format!("Approved-by: {} <tester@gunnar.invalid>", tester.name),
    ] {
        assert!(
            trailers.lines().any(|l| l == line),
            "the merge commit does not record {line:?}:\n{trailers}"
        );
    }
    assert!(
        !trailers.to_ascii_lowercase().contains("co-authored-by"),
        "no attribution trailers on a merge commit:\n{trailers}"
    );
    let subject = git_out(&harness, &clone, &["log", "-1", "--format=%s"]);
    assert_eq!(
        subject.trim(),
        format!(
            "Merge pull request #{} from topic into main",
            proposal.number
        )
    );

    harness.stop();
}

/// **A conflict is refused by path, the check lists it before the click, and
/// nothing moves.**
#[test]
fn a_conflict_is_refused_by_path_and_nothing_is_written() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let moved = main_moves_on(
        &harness,
        &[("a.txt", "base\nmain's own line\n"), ("c.txt", "clean\n")],
        "main edits a.txt too",
    );
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );

    let (_, would) = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("the check answers conflicts as data");
    assert_eq!(
        would.conflicts,
        vec!["a.txt (both sides changed the same lines)".to_owned()],
        "the check lists every conflicting path, and only those"
    );
    assert_eq!(strategy(&would), gunnar_proto::MergeStrategy::MergeCommit);

    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &moved,
        &["merge conflict", "a.txt", "Nothing was written"],
    );
    assert!(
        !message.contains("c.txt"),
        "a path only one side changed is no conflict: {message}"
    );

    harness.stop();
}

/// **A target that moved after the check is checked again against the new tip.**
///
/// The check answers a fast-forward onto the tip it saw. `main` then moves: the
/// merge, told which tip the check saw, says it checked again, and makes a merge
/// commit onto the NEW tip — never the fast-forward the old answer described,
/// which would drop `main`'s new commit. And when the new tip conflicts, the
/// re-check refuses by path.
#[test]
fn a_target_that_moved_after_the_check_is_checked_again() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );
    let (_, seen) = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a fast-forward checks");
    assert_eq!(strategy(&seen), gunnar_proto::MergeStrategy::FastForward);
    assert_eq!(seen.old_oid, proposal.base);
    assert!(!seen.rechecked);

    let moved = main_moves_on(&harness, &[("b.txt", "meanwhile\n")], "meanwhile");
    let options = MergeOptions {
        expected_target_oid: Some(seen.old_oid.clone()),
        ..MergeOptions::default()
    };
    let (_, merged) = plane
        .merge_pull(&path(), proposal.number, "tester", &options)
        .expect("the re-checked merge goes through");
    assert!(merged.rechecked, "the merge must say it checked again");
    assert_eq!(merged.old_oid, moved, "made onto the new tip");
    assert_eq!(strategy(&merged), gunnar_proto::MergeStrategy::MergeCommit);
    let clone = clone_of(&harness, "rechecked");
    let parents = git_out(&harness, &clone, &["rev-list", "--parents", "-n1", "HEAD"]);
    assert_eq!(
        parents.split_whitespace().collect::<Vec<_>>(),
        vec![
            merged.new_oid.as_str(),
            moved.as_str(),
            proposal.topic.as_str()
        ]
    );

    harness.stop();
}

/// **The re-check refuses a target that moved onto a conflict**, by path.
#[test]
fn a_target_that_moved_onto_a_conflict_is_refused_on_the_recheck() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );
    let (_, seen) = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a fast-forward checks");
    let moved = main_moves_on(&harness, &[("a.txt", "base\nconflicting\n")], "conflicting");
    let err = plane
        .merge_pull(
            &path(),
            proposal.number,
            "tester",
            &MergeOptions {
                expected_target_oid: Some(seen.old_oid),
                ..MergeOptions::default()
            },
        )
        .expect_err("a conflict on the new tip was merged");
    assert!(
        format!("{err}").contains("merge conflict") && format!("{err}").contains("a.txt"),
        "{err}"
    );
    assert_eq!(tip(&harness, TARGET).as_deref(), Some(moved.as_str()));
    assert_eq!(
        state_of(&plane, proposal.number),
        PullRequestState::Open as i32
    );

    harness.stop();
}

/// **A head already in the target was merged outside Gunnar** (GUN-PR-FIELD-2026-11-01,
/// item 3): somebody pushed the branch's commits to main directly. Nothing is left to
/// merge and no ref moves: the check says so and names the commit on the target that
/// contains the head, and Merge marks the record merged at that commit — because the head
/// carries the approval the target's review policy asks (SEC-G1: without it the pull
/// request is closed, "closed: content already on target", never recorded merged).
#[test]
fn a_head_already_in_the_target_is_marked_merged_outside_gunnar() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let work = harness.dir.path().join("work");
    // The branch lands on main outside Gunnar, and main moves one further.
    harness
        .git(&work, &["checkout", "--quiet", "main"])
        .expect_ok("checkout main");
    harness
        .git(&work, &["merge", "--quiet", "--ff-only", "topic"])
        .expect_ok("fast-forward main locally");
    let containing = main_moves_on(&harness, &[("later.txt", "later\n")], "later work on main");
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );

    let (pull, would) = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("an approved head already in the target checks");
    assert_eq!(pull.state, PullRequestState::Open as i32);
    assert_eq!(
        would.contained_in, containing,
        "the check names the containing commit"
    );
    assert_eq!(would.strategy, gunnar_proto::MergeStrategy::Unspecified);

    let (merged, done) = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a head merged outside Gunnar is marked merged");
    assert_eq!(merged.state, PullRequestState::Merged as i32);
    assert_eq!(merged.merge_oid, containing);
    assert_eq!(done.contained_in, containing);
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(containing.as_str()),
        "nothing to move, and nothing moved"
    );

    harness.stop();
}

/// Store a policy document for the repository, as the `Policy` service would.
fn set_policy(harness: &Harness, json: &str) {
    let repo = gunnar_core::RepoRef::new(NAMESPACE, NAME).expect("a legal repo path");
    gunnar_policy::PolicyConfig::from_json(json).expect("the policy parses");
    harness
        .stores
        .policies()
        .set(&repo, json)
        .expect("store the policy");
}

/// **A fast-forward-only repository refuses a branch behind its target**, and an
/// explicit fast-forward ask is refused the same way anywhere.
#[test]
fn a_fast_forward_only_repository_refuses_a_branch_behind_its_target() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let moved = main_moves_on(&harness, &[("b.txt", "meanwhile\n")], "meanwhile");
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.topic,
    );

    let err = plane
        .merge_pull(
            &path(),
            proposal.number,
            "tester",
            &MergeOptions {
                strategy: gunnar_proto::MergeStrategy::FastForward,
                ..MergeOptions::default()
            },
        )
        .expect_err("a fast-forward of a branch behind its target");
    let said = format!("{err}");
    assert!(
        said.contains("not a fast-forward") && said.contains(SOURCE) && said.contains(TARGET),
        "{said}"
    );

    set_policy(&harness, r#"{"merge": {"allow_merge_commit": false}}"#);
    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &moved,
        &[
            "not a fast-forward",
            "does not descend from",
            "only fast-forward merges",
        ],
    );
    assert!(message.contains("rebase or merge locally"), "{message}");

    harness.stop();
}

/// **A squash is one commit on the target tip — only where the policy turns it on.**
#[test]
fn a_squash_is_one_commit_on_the_target_and_only_where_allowed() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let moved = main_moves_on(&harness, &[("b.txt", "meanwhile\n")], "meanwhile");
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.topic,
    );
    let squash = MergeOptions {
        strategy: gunnar_proto::MergeStrategy::Squash,
        ..MergeOptions::default()
    };
    let err = plane
        .merge_pull(&path(), proposal.number, "tester", &squash)
        .expect_err("a squash the policy did not turn on");
    assert!(format!("{err}").contains("merge.allow_squash"), "{err}");
    assert_eq!(tip(&harness, TARGET).as_deref(), Some(moved.as_str()));

    set_policy(&harness, r#"{"merge": {"allow_squash": true}}"#);
    let (_, merged) = plane
        .merge_pull(&path(), proposal.number, "tester", &squash)
        .expect("an allowed squash");
    assert_eq!(strategy(&merged), gunnar_proto::MergeStrategy::Squash);
    let clone = clone_of(&harness, "squashed");
    git_out(
        &harness,
        &clone,
        &["fsck", "--full", "--strict", "--no-dangling"],
    );
    let parents = git_out(&harness, &clone, &["rev-list", "--parents", "-n1", "HEAD"]);
    assert_eq!(
        parents.split_whitespace().collect::<Vec<_>>(),
        vec![merged.new_oid.as_str(), moved.as_str()],
        "a squash has the target tip as its only parent"
    );
    let body = git_out(&harness, &clone, &["log", "-1", "--format=%B"]);
    assert!(
        body.contains("* the proposal"),
        "the squashed subjects: {body}"
    );
    assert!(
        body.contains(&format!("Head: {}", proposal.topic)),
        "{body}"
    );
    assert_eq!(
        std::fs::read_to_string(clone.join("a.txt")).unwrap(),
        "base\nproposed\n"
    );
    assert_eq!(
        std::fs::read_to_string(clone.join("b.txt")).unwrap(),
        "meanwhile\n"
    );

    harness.stop();
}

/// Register a second identity-table principal with its own fresh key, and hand the key
/// back so the principal can sign approvals.
///
/// Through the same `AuthTable` calls the harness seeds `tester` with, because
/// the arms below are about the MERGE's judgement of a registered principal —
/// not about registration, which `second_key.rs` owns.
fn register_principal(harness: &Harness, id: &str) -> gunnar_core::ssh_key::PrivateKey {
    let private = gunnar_core::mint_ed25519().expect("mint a key");
    let parsed =
        gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the minted key parses");
    let public = parsed
        .public_key()
        .to_openssh()
        .expect("render the public key");
    harness
        .stores
        .auth()
        .add_principal(
            gunnar_core::Principal::new(id, id, gunnar_core::PrincipalKind::User).expect("valid"),
        )
        .expect("add the principal");
    harness
        .stores
        .auth()
        .register_key(id, &public, None)
        .expect("register the key");
    parsed
}

/// Write one protection rule on `refs/heads/main`, through the same
/// `set_protection` the `Policy` service calls, so what is exercised is the
/// stored policy document and not a knob the tests alone can reach.
fn protect_main(plane: &CoreControlPlane, forbid_self_approval: bool, required_approvals: u32) {
    plane
        .set_protection(
            &path(),
            gunnar_proto::BranchProtection {
                ref_pattern: TARGET.into(),
                forbid_self_approval,
                required_approvals,
                ..Default::default()
            },
        )
        .expect("store the protection rule");
}

/// **An approval signed by somebody ELSE'S registered key is not the actor's
/// approval, and the merge refuses.**
///
/// The hole this closes: `review_state` used to look the signing key up in the
/// whole identity table by fingerprint, so an approval CLAIMING `actor: tester` but
/// signed by any other registered principal's key verified — and merged. A
/// signature proves which KEY spoke; only the key table's `principal_id` says
/// whose voice that is, and an approval whose signature does not verify against
/// the CLAIMED actor's own registered key is a claim, not evidence.
///
/// Green half: the same commit approved by the actor's own key merges, so the
/// refusal above is demonstrably about the binding and nothing else.
#[test]
fn an_approval_signed_by_anothers_key_is_not_the_actors() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    // mallory is REAL: a registered principal with a registered key. Her
    // signature is perfectly good — it is simply not tester's.
    let mallory_key = register_principal(&harness, "mallory");
    approve(
        &harness,
        &mallory_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.topic,
    );

    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &["no verified approval names", "What is approved:"],
    );
    assert!(
        message.contains("mallory"),
        "the refusal must say WHOSE key actually signed, or the operator is told \
         a good signature failed: {message}"
    );

    // The actor's own key. Nothing else changes.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("the actor's own key's approval of the tip must merge");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the approval verified and the branch did not move"
    );

    harness.stop();
}

/// **Where the policy forbids it, the author's own approval does not merge
/// their own pull request.**
///
/// The red this rule exists for is the suite itself:
/// `a_verified_approval_of_the_tip_fast_forwards_the_branch` merges on the
/// AUTHOR'S own approval — `tester` opens the pull request and `tester` signs
/// off on it — and that stays the default, because a solo operator's appliance
/// with a review rule nobody can satisfy is a locked door with no key. The rule
/// is a `ProtectionRule` field in the same policy document as branch
/// protection, so "may the author approve their own work" is decided where
/// "who may push to main" already is.
///
/// Green half: a second registered principal approves the same commit, and the
/// same merge goes through — the refusal was about WHO, not about the commit.
#[test]
fn the_author_cannot_self_approve_where_the_policy_forbids_it() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    protect_main(&plane, true, 0);

    // The author's own approval: verified, of the exact tip, and their own.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.topic,
    );

    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &["forbids self-approval", "tester"],
    );
    assert!(
        message.contains(TARGET),
        "the refusal must name the protected ref the rule sits on: {message}"
    );

    // Somebody else looks at the same commit and says so.
    let reviewer_key = register_principal(&harness, "reviewer");
    approve(
        &harness,
        &reviewer_key,
        proposal.number,
        "reviewer",
        1_700_000_002_000,
        &proposal.topic,
    );
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a reviewer who is not the author must satisfy the rule");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the reviewer's approval satisfied the rule and the branch did not move"
    );

    harness.stop();
}

/// **`required_approvals: 2` needs two DISTINCT verified approvers — and two
/// approvals from the same principal count as one.**
///
/// The second fact is the one worth paying for: a count of approval EVENTS
/// would let one reviewer press the button twice, and the fold already keys
/// approvals by actor precisely so that cannot be a state. This arm proves the
/// gate reads that map and not the log's length.
#[test]
fn required_approvals_count_distinct_principals_not_events() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    protect_main(&plane, false, 2);

    // One verified approval of the tip.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_001_000,
        &proposal.topic,
    );
    let message = refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &["2 distinct", "tester"],
    );
    assert!(
        message.contains(TARGET),
        "the refusal must name the protected ref the rule sits on: {message}"
    );

    // The same principal, again. Still one voice.
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );
    refuses(
        &harness,
        &plane,
        proposal.number,
        &proposal.base,
        &["2 distinct"],
    );

    // A second principal. Two voices, and the merge goes.
    let reviewer_key = register_principal(&harness, "reviewer");
    approve(
        &harness,
        &reviewer_key,
        proposal.number,
        "reviewer",
        1_700_000_003_000,
        &proposal.topic,
    );
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("two distinct verified approvers must satisfy required_approvals: 2");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the second approver satisfied the rule and the branch did not move"
    );

    harness.stop();
}

// ───────────────────────────────────────────────────────────────────────────
// Branch protection on the merge path (GUN-004)
// ───────────────────────────────────────────────────────────────────────────
//
// `merge_pull` read the policy document and took exactly two fields out of it
// — `forbid_self_approval` and `required_approvals` — then moved the ref with
// `update_ref`. `allowed_principals` and `require_signature` sit in the SAME
// `ProtectionRule` and were evaluated only in `gates::protection`, which the
// merge path never went through. The structural cause was that the caller's
// identity never crossed the seam: `merge_pull(path, number)` could not answer
// a principal-dependent question because it did not know who was asking.
//
// The realistic attacker is a contributor in somebody else's namespace:
// `Level::Write` on the repository, a registered key, and not the account
// owner. The push path refuses them by name; this was the side door.

/// Write a protection rule on `main` with an allowlist, through the same
/// `set_protection` the `Policy` service calls.
fn protect_main_for(plane: &CoreControlPlane, allowed: &[&str]) {
    plane
        .set_protection(
            &path(),
            gunnar_proto::BranchProtection {
                ref_pattern: TARGET.into(),
                allowed_principal_ids: allowed.iter().map(|s| (*s).to_owned()).collect(),
                ..Default::default()
            },
        )
        .expect("store the protection rule");
}

/// **The side door, closed.** A principal outside `allowed_principal_ids`
/// cannot move a protected ref by merging into it.
///
/// Mutation-proven 2026-09-11: take the `BranchProtectionGate::check` block out
/// of `merge_pull` and this goes green on the merge — `main` moves to the topic
/// tip and the record is marked merged, for a principal the rule names as not
/// allowed to move it at all.
#[test]
fn a_merge_cannot_move_a_ref_the_allowlist_keeps_the_caller_off() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    // A second real principal: registered, with a registered key, who approves
    // properly. Everything about the review is in order — it is only the
    // protection that says this person may not move `main`.
    let reviewer_key = register_principal(&harness, "reviewer");
    approve(
        &harness,
        &reviewer_key,
        proposal.number,
        "reviewer",
        1_700_000_001_000,
        &proposal.topic,
    );
    protect_main_for(&plane, &["release-manager"]);

    let err = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect_err("a principal off the allowlist merged into a protected ref");
    let message = format!("{err}");
    assert!(
        message.contains("allowed principals") && message.contains("tester"),
        "the refusal must name the rule and the principal it turned away: {message}"
    );
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.base.as_str()),
        "the merge was refused and the branch moved anyway"
    );
    assert_eq!(
        plane
            .get_pull(&path(), proposal.number)
            .expect("read the record")
            .state,
        PullRequestState::Open as i32,
        "a refused merge must leave the record open"
    );

    // The other direction, on the same fixture: put the caller ON the
    // allowlist and the identical merge goes through. Without this the test
    // above would be satisfied by a merge path that refused everything.
    protect_main_for(&plane, &["tester"]);
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a principal the allowlist names must be able to merge");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str()),
        "the allowed merge did not move the branch"
    );
    harness.stop();
}

/// `require_signature` reaches the merge path too, over the commits the merge
/// actually adds.
///
/// A merge carries no pack, so the pack's new-object set — every other gate's
/// answer to "what did this introduce" — is empty here. Handing that to the
/// signature check would make `require_signature` pass by having nothing to
/// look at, which is the same vacuous allow GUN-003 was. `commits_between`
/// gives it the range `target..source` instead, and the fixture's topic commit
/// is unsigned, so the rule bites.
#[test]
fn a_merge_of_unsigned_history_into_a_require_signature_ref_is_refused() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    let reviewer_key = register_principal(&harness, "reviewer");
    approve(
        &harness,
        &reviewer_key,
        proposal.number,
        "reviewer",
        1_700_000_001_000,
        &proposal.topic,
    );
    plane
        .set_protection(
            &path(),
            gunnar_proto::BranchProtection {
                ref_pattern: TARGET.into(),
                require_signature: true,
                ..Default::default()
            },
        )
        .expect("store the protection rule");

    let err = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect_err("unsigned history was merged into a require_signature ref");
    let message = format!("{err}");
    assert!(
        message.contains("requires signed commits") && message.contains("is not signed"),
        "the refusal must say which rule and which commit: {message}"
    );
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.base.as_str()),
        "the merge was refused and the branch moved anyway"
    );

    // …and the same merge with the rule off goes through, so the refusal is
    // about the signature and not about merges in general.
    plane
        .delete_protection(&path(), TARGET)
        .expect("drop the protection rule");
    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("the merge must go through once the rule is gone");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str())
    );
    harness.stop();
}

/// **A merge checked ahead says what the merge would refuse, and moves nothing**
/// (GUN-FIELD-TEST, 2026-11-01). The console offered "Merge (fast-forward)" on a pull
/// request nobody had approved; pressing it was refused. `check_merge` runs the merge's
/// own checks and stops before the ref, so the console can show gunnar's sentence
/// instead of a button that cannot work.
#[test]
fn a_merge_checked_ahead_gives_the_merges_own_refusal_and_moves_nothing() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);

    let err = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect_err("an unapproved pull request was checked as mergeable");
    assert!(
        matches!(err, ControlError::FailedPrecondition(_)),
        "the check answers the merge's own refusal, a failed precondition: {err:?}"
    );
    let said = format!("{err}");
    assert!(
        said.contains("no verified approval names") && said.contains("Nobody has approved"),
        "the check says what the merge would say: {said}"
    );
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.base.as_str())
    );
    assert_eq!(
        state_of(&plane, proposal.number),
        PullRequestState::Open as i32
    );

    harness.stop();
}

/// **An approved fast-forward checks as mergeable, and the check still moves nothing;
/// the merge after it does** (GUN-FIELD-TEST, 2026-11-01).
#[test]
fn an_approved_pull_request_checks_as_mergeable_and_only_the_merge_moves_it() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );

    let (pull, would) = plane
        .check_merge(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("an approved fast-forward checks as mergeable");
    assert_eq!(
        pull.state,
        PullRequestState::Open as i32,
        "the check is not a merge"
    );
    assert_eq!(
        (
            would.ref_name.as_str(),
            would.old_oid.as_str(),
            would.new_oid.as_str()
        ),
        (TARGET, proposal.base.as_str(), proposal.topic.as_str()),
        "the check answers what the merge would move"
    );
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.base.as_str()),
        "the check moved {TARGET}"
    );
    assert_eq!(
        state_of(&plane, proposal.number),
        PullRequestState::Open as i32
    );

    plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("the merge after the check goes through");
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.topic.as_str())
    );

    harness.stop();
}

/// ★ **SEC-G1 (sec review 2026-11-01): "merged outside Gunnar" is no approval claim.** A Write
/// user force-pushes the pull request's branch to the target's tip (or an ancestor of it) and
/// presses Merge: nothing is left to merge, and nobody approved anything. The record must not
/// say MERGED (webhooks and changelogs believe it); it is closed, saying the content is already
/// on the target, and no ref moves.
#[test]
fn a_branch_reset_onto_its_target_is_not_recorded_merged_without_an_approval() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed(&harness, &plane);
    let work = harness.dir.path().join("work");
    harness
        .git(
            &work,
            &[
                "push",
                "--quiet",
                "--force",
                &harness.url(NAMESPACE, NAME),
                "main:topic",
            ],
        )
        .expect_ok("reset topic onto main");
    assert_eq!(
        tip(&harness, SOURCE).as_deref(),
        Some(proposal.base.as_str())
    );

    let outcome = plane.merge_pull(&path(), proposal.number, "tester", &MergeOptions::default());
    let state = state_of(&plane, proposal.number);
    assert_ne!(
        state,
        PullRequestState::Merged as i32,
        "a pull request nobody approved is recorded merged: {outcome:?}"
    );
    assert_eq!(
        tip(&harness, TARGET).as_deref(),
        Some(proposal.base.as_str()),
        "nothing moved"
    );
    let pull = plane.get_pull(&path(), proposal.number).expect("read back");
    assert!(
        pull.merge_oid.is_empty(),
        "no merge commit is claimed: {pull:?}"
    );
    assert_eq!(
        state,
        PullRequestState::Closed as i32,
        "closed: content already on target"
    );
    let (_, done) = outcome.expect("nothing to merge is an answer, not an error");
    assert_eq!(done.closed, gunnar_grpc::CONTENT_ALREADY_ON_TARGET);
    assert!(done.approvers.is_empty(), "no approval is claimed");

    harness.stop();
}

/// ★ **GUN-MERGE-SIGN: gunnar signs the merge commits it writes, so a repository that
/// requires signed commits takes them** (owner 2026-11-01: merge commits were unsigned,
/// so a repository requiring signed commits refused every merge gunnar made).
///
/// Signed history (the harness key) behind its target, `main` under branch protection
/// `require_signature` AND the signed-commits policy, scope "the pusher's own keys": the
/// merge commit is written, signed by gunnar's merge-signing key, and taken. The public
/// half is `PullRequests.MergeSigningKey`'s; it is no principal's key; and a real git
/// client verifies the merge commit with it (`git verify-commit` over an allowed signers
/// file holding the answer's line).
#[test]
fn a_merge_commit_is_signed_by_gunnar_and_a_signed_commits_repository_takes_it() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let plane = control_plane(&harness);
    let proposal = proposed_with(&harness, &plane, true);
    let _moved = main_moves_on(&harness, &[("b.txt", "meanwhile\n")], "meanwhile");
    plane
        .set_protection(
            &path(),
            gunnar_proto::BranchProtection {
                ref_pattern: TARGET.into(),
                require_signature: true,
                ..Default::default()
            },
        )
        .expect("store the protection rule");
    set_policy(
        &harness,
        r#"{"signatures":{"required":true,"scope":"pusher_keys_only"}}"#,
    );
    approve(
        &harness,
        &harness.private_key,
        proposal.number,
        "tester",
        1_700_000_002_000,
        &proposal.topic,
    );

    let (_, result) = plane
        .merge_pull(&path(), proposal.number, "tester", &MergeOptions::default())
        .expect("a merge commit gunnar signed is taken where signatures are required");
    assert_eq!(strategy(&result), gunnar_proto::MergeStrategy::MergeCommit);
    let merge = result.new_oid.clone();
    assert_eq!(tip(&harness, TARGET).as_deref(), Some(merge.as_str()));

    let (openssh, fingerprint) = plane
        .merge_signing_key()
        .expect("the merge-signing key's public half");
    assert!(openssh.starts_with("ssh-ed25519 "), "{openssh}");
    assert!(fingerprint.starts_with("SHA256:"), "{fingerprint}");
    let public = gunnar_core::ssh_key::PublicKey::from_openssh(&openssh).expect("one OpenSSH line");
    assert!(
        harness
            .stores
            .auth()
            .signing_keys()
            .expect("the identity table")
            .iter()
            .all(|row| row.fingerprint_sha256 != fingerprint),
        "the merge-signing key is no principal's key"
    );
    assert_eq!(
        plane.merge_signing_key().expect("asked again").1,
        fingerprint,
        "one key, minted once"
    );

    let clone = clone_of(&harness, "signed-merge");
    let raw = git_out(&harness, &clone, &["cat-file", "commit", "HEAD"]);
    let check = gunnar_policy::signature::check_commit_bytes(
        gix_hash::ObjectId::from_hex(merge.as_bytes()).expect("an oid"),
        raw.as_bytes(),
        &[public],
        gunnar_policy::signature::GIT_NAMESPACE,
    );
    assert!(
        check.is_verified(),
        "the merge commit is gunnar's signature: {check:?}\n{raw}"
    );

    let signers = harness.dir.path().join("allowed_signers");
    std::fs::write(
        &signers,
        format!(
            "{}\n",
            gunnar_grpc::service::pulls::allowed_signers_line(&openssh)
        ),
    )
    .expect("write the allowed signers file");
    let verified = harness.git(
        &clone,
        &[
            "-c",
            &format!("gpg.ssh.allowedSignersFile={}", signers.display()),
            "verify-commit",
            "HEAD",
        ],
    );
    assert!(
        verified.ok,
        "git verify-commit refused gunnar's merge commit:\n{}{}",
        verified.stdout, verified.stderr
    );
    harness.stop();
}