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